Guide contents

Lesson 19 of 44

TCP Clients and Servers

TCP is a reliable, ordered byte stream between two programs. Tokio exposes it as TcpListener (accepts connections), TcpStream (one connection), and TcpSocket (configure a socket before it connects or listens). A TcpStream implements AsyncRead and AsyncWrite from AsyncRead and AsyncWrite, so every helper from that lesson works on it.

Item Purpose
TcpListener::bind, accept, local_addr listen for connections
TcpStream::connect open a connection
split, into_split read and write the same connection from different places
shutdown half-close: tell the peer no more data is coming
peek, set_nodelay inspect without consuming, control packet batching
TcpSocket set the backlog and socket options before listening
io::copy_bidirectional forward bytes both ways between two streams
ErrorKind::ConnectionRefused, ConnectionReset, BrokenPipe, UnexpectedEof the errors a connection produces

Every server below binds to 127.0.0.1:0. Port 0 asks the operating system for any free port, so the examples never collide with other programs. The program reads the chosen address from local_addr() and hands it to the client in the same process. The examples never print addresses or ports.

Example 1: One server, one client

The smallest complete program binds a listener, accepts one connection in a spawned task, and connects to it. The server writes a greeting and drops the connection, which the client sees as EOF.

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

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

    let server = tokio::spawn(async move {
        let (mut stream, _peer) = listener.accept().await.unwrap();
        stream.write_all(b"hello from the server").await.unwrap();
    });

    let mut client = TcpStream::connect(addr).await?;
    let mut message = String::new();
    client.read_to_string(&mut message).await?;
    println!("client received: {message}");

    server.await.unwrap();
    Ok(())
}
Output
client received: hello from the server

bind creates the listening socket. accept waits for a connection and returns a TcpStream plus the peer's address, which this example ignores. connect can finish before the server calls accept, because the operating system completes the handshake and queues the connection until accept takes it (the queue is the backlog, Example 11). When the server task ends, the TcpStream is dropped, which closes the connection.

Example 2: Addresses agree on both ends

local_addr returns the address a socket is bound to. peer_addr returns the address on the other end. The two ends of one connection mirror each other, so the checks below print true without printing any address.

Rust
use tokio::net::{TcpListener, TcpStream};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:0").await?;
    let listen_addr = listener.local_addr()?;
    println!("port was assigned: {}", listen_addr.port() != 0);

    let client = TcpStream::connect(listen_addr).await?;
    let (server_side, client_addr_seen_by_server) = listener.accept().await?;

    println!("client peer is the listener: {}", client.peer_addr()? == listen_addr);
    println!("server sees the client's address: {}", client_addr_seen_by_server == client.local_addr()?);
    println!("accepted socket is on the listen address: {}", server_side.local_addr()? == listen_addr);
    Ok(())
}
Output
port was assigned: true
client peer is the listener: true
server sees the client's address: true
accepted socket is on the listen address: true

The accepted socket has the same local address as the listener but is a different socket, one per connection. The listener keeps accepting more.

Example 3: TCP has no message boundaries

TCP delivers bytes in order but does not remember how the sender grouped them. Two writes can arrive as one read, and one write can arrive as several reads. Here the client sends ab and cd in two writes, and the server uses read_exact to collect four bytes regardless of how they arrive.

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

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

    let server = tokio::spawn(async move {
        let (mut stream, _) = listener.accept().await.unwrap();
        let mut four = [0u8; 4];
        stream.read_exact(&mut four).await.unwrap();
        String::from_utf8(four.to_vec()).unwrap()
    });

    let mut client = TcpStream::connect(addr).await?;
    client.write_all(b"ab").await?;
    yield_now().await;
    client.write_all(b"cd").await?;

    println!("server assembled: {}", server.await.unwrap());
    Ok(())
}
Output
server assembled: abcd

The program prints only the assembled bytes, never the number of reads, because that number depends on timing. A protocol on top of TCP must mark where messages end: a delimiter such as a newline, a length prefix, or a fixed size. Bytes and Framing covers this.

Example 4: The accept loop with one task per connection

A server handles many clients by looping on accept and spawning a task per connection, so a slow client never blocks the loop. This echo server accepts three connections. Each handler copies its read half to its write half and returns the number of bytes it echoed.

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

async fn echo(mut stream: TcpStream) -> io::Result<u64> {
    let (mut reader, mut writer) = stream.split();
    io::copy(&mut reader, &mut writer).await
}

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

    let server = tokio::spawn(async move {
        let mut handlers = Vec::new();
        for _ in 0..3 {
            let (stream, _) = listener.accept().await.unwrap();
            handlers.push(tokio::spawn(echo(stream)));
        }
        let mut total = 0;
        for handler in handlers {
            total += handler.await.unwrap().unwrap();
        }
        total
    });

    let mut clients = Vec::new();
    for id in 1..=3 {
        clients.push(tokio::spawn(async move {
            let mut stream = TcpStream::connect(addr).await.unwrap();
            stream.write_all(format!("client {id} says hi").as_bytes()).await.unwrap();
            stream.shutdown().await.unwrap();
            let mut reply = String::new();
            stream.read_to_string(&mut reply).await.unwrap();
            reply
        }));
    }

    for client in clients {
        println!("{}", client.await.unwrap());
    }
    println!("server echoed {} bytes", server.await.unwrap());
    Ok(())
}
Output
client 1 says hi
client 2 says hi
client 3 says hi
server echoed 48 bytes

Each client calls shutdown after its message, so the echo handler's copy sees EOF, finishes, and drops the connection. That drop makes the client's read_to_string finish. The replies print in client order because the program awaits the client handles in order. The order in which the server accepted the clients is not printed, since it varies.

A real server runs loop { accept; spawn } forever. This example stops after three so that the program ends by itself.

Example 5: Errors from accept

accept can fail for reasons that affect one attempt, not the listener, such as the process running out of file descriptors. A server that returns from main on the first accept error goes down for a problem that may pass. The usual shape logs the error and continues. The loop below shows the shape and stops after two successful connections.

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

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

    let server = tokio::spawn(async move {
        let mut served = 0;
        while served < 2 {
            match listener.accept().await {
                Ok((mut stream, _)) => {
                    served += 1;
                    tokio::spawn(async move {
                        let _ = stream.write_all(b"ok").await;
                    });
                }
                Err(e) => {
                    // A failed accept is logged and the loop continues.
                    eprintln!("accept failed: {e}");
                }
            }
        }
        served
    });

    for _ in 0..2 {
        let _client = TcpStream::connect(addr).await?;
    }
    println!("connections served: {}", server.await.unwrap());
    Ok(())
}
Output
connections served: 2

accept is cancel safe (the method documentation guarantees that no connection was accepted if another select! branch wins), so an accept loop can be one branch of a select! that also watches for a shutdown signal. Graceful Shutdown builds on this.

Example 6: Half-close with shutdown

shutdown().await on a TcpStream closes only the write direction. The peer's reads return EOF, but the peer can still write and the caller can still read. A client uses this to say "that is the whole request" and then wait for the response.

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

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

    let server = tokio::spawn(async move {
        let (mut stream, _) = listener.accept().await.unwrap();
        let mut request = Vec::new();
        stream.read_to_end(&mut request).await.unwrap();
        let reply = format!("received {} bytes", request.len());
        stream.write_all(reply.as_bytes()).await.unwrap();
    });

    let mut client = TcpStream::connect(addr).await?;
    client.write_all(b"a request of unknown length to the server").await?;
    client.shutdown().await?;

    let mut reply = String::new();
    client.read_to_string(&mut reply).await?;
    println!("{reply}");
    server.await.unwrap();
    Ok(())
}
Output
received 41 bytes

Without the shutdown, the server's read_to_end would wait for EOF forever while the client waited for the reply. This is the standard cause of a "both sides are waiting" hang.

Example 7: split for concurrent reading and writing in one task

stream.split() borrows the stream and returns a ReadHalf and a WriteHalf. It has no allocation or lock, but both halves must stay in the same task because they borrow the stream. tokio::join! (see join!, try_join!, and Running Work Concurrently) runs a reader and a writer together inside that task.

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

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

    let server = tokio::spawn(async move {
        let (mut stream, _) = listener.accept().await.unwrap();
        let mut text = String::new();
        stream.read_to_string(&mut text).await.unwrap();
        stream.write_all(text.to_uppercase().as_bytes()).await.unwrap();
    });

    let mut client = TcpStream::connect(addr).await?;
    let (mut reader, mut writer) = client.split();

    let send = async {
        writer.write_all(b"shout this").await?;
        writer.shutdown().await
    };
    let receive = async {
        let mut reply = String::new();
        reader.read_to_string(&mut reply).await?;
        Ok::<_, std::io::Error>(reply)
    };

    let (sent, received) = tokio::join!(send, receive);
    sent?;
    println!("{}", received?);
    server.await.unwrap();
    Ok(())
}
Output
SHOUT THIS

Example 8: into_split for separate tasks

into_split consumes the stream and returns an OwnedReadHalf and an OwnedWriteHalf. They own their share of the connection (through a heap allocation), so each can move into its own spawned task. Dropping the write half shuts down the write direction, the same as calling shutdown.

Rust
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};

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

    // Server: numbers each line it receives.
    let server = tokio::spawn(async move {
        let (stream, _) = listener.accept().await.unwrap();
        let (reader, mut writer) = stream.into_split();
        let mut lines = BufReader::new(reader).lines();
        let mut n = 0;
        while let Some(line) = lines.next_line().await.unwrap() {
            n += 1;
            writer.write_all(format!("{n}: {line}\n").as_bytes()).await.unwrap();
        }
    });

    let client = TcpStream::connect(addr).await?;
    let (reader, mut writer) = client.into_split();

    // A separate task owns the write half and sends three lines.
    let sender = tokio::spawn(async move {
        for word in ["red", "green", "blue"] {
            writer.write_all(format!("{word}\n").as_bytes()).await.unwrap();
        }
        // `writer` is dropped here, which shuts down the write direction.
    });

    let mut lines = BufReader::new(reader).lines();
    while let Some(line) = lines.next_line().await? {
        println!("{line}");
    }
    sender.await.unwrap();
    server.await.unwrap();
    Ok(())
}
Output
1: red
2: green
3: blue

The sender task ended, its write half dropped, and the server saw EOF and finished. That closed the server's connection, and the client's lines loop ended. The split lets one task keep reading while another writes, which a chat client needs when it prints incoming messages and sends typed ones at the same time.

Method Halves Cost Where the halves can go
split ReadHalf<'_>, WriteHalf<'_> (borrow the stream) none the same task only
into_split OwnedReadHalf, OwnedWriteHalf one heap allocation any task
tokio::io::split ReadHalf<T>, WriteHalf<T> (works for any stream) a lock any task, if T is Send

Example 9: peek looks without consuming

peek copies available bytes into a buffer but leaves them in the socket, so the next read returns them again. It helps a server decide which handler to run from the first bytes. peek returns what has arrived so far, which can be fewer bytes than the sender wrote, so the loop waits until enough bytes are there.

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

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

    let mut client = TcpStream::connect(addr).await?;
    let (mut server_side, _) = listener.accept().await?;
    client.write_all(b"GET /index").await?;

    let mut probe = [0u8; 3];
    while server_side.peek(&mut probe).await? < 3 {
        yield_now().await;
    }
    println!("peeked: {:?}", std::str::from_utf8(&probe).unwrap());

    let mut all = [0u8; 10];
    server_side.read_exact(&mut all).await?;
    println!("read after peek: {:?}", std::str::from_utf8(&all).unwrap());
    Ok(())
}
Output
peeked: "GET"
read after peek: "GET /index"

The peeked GET bytes were still there for read_exact. peek is cancel safe, and a server can use it to tell protocols apart (for example TLS versus plain text) before handing the stream to a parser.

Example 10: set_nodelay

Nagle's algorithm delays small writes so the operating system can combine them into fewer packets. That saves packets but can add a delay to request/response traffic made of small messages. set_nodelay(true) turns the algorithm off (this sets the TCP_NODELAY option). It defaults to false.

Rust
use tokio::net::{TcpListener, TcpStream};

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

    println!("default nodelay: {}", client.nodelay()?);
    client.set_nodelay(true)?;
    println!("after set_nodelay(true): {}", client.nodelay()?);
    Ok(())
}
Output
default nodelay: false
after set_nodelay(true): true

Set nodelay on latency-sensitive protocols (RPC, interactive text). Streaming bulk data gains nothing from it. A server calls it on each accepted stream. A BufWriter that gathers a whole message before one write also avoids the small-packet problem.

Example 11: TcpSocket, backlog, and reuseaddr

TcpListener::bind performs socket creation, bind, and listen in one call. TcpSocket splits those steps so that options can be set in between. listen(backlog) sets how many completed connections the operating system queues before accept takes them; when the queue is full, new connections are refused or delayed. set_reuseaddr(true) allows binding to an address that has connections in the TIME_WAIT state, which matters when a server restarts on a fixed port. (TcpListener::bind already sets SO_REUSEADDR on Unix, as the mio documentation states.)

Rust
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpSocket;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let socket = TcpSocket::new_v4()?;
    socket.set_reuseaddr(true)?;
    println!("reuseaddr: {}", socket.reuseaddr()?);
    socket.bind("127.0.0.1:0".parse().unwrap())?;
    let listener = socket.listen(128)?;
    let addr = listener.local_addr()?;

    let server = tokio::spawn(async move {
        let (mut stream, _) = listener.accept().await.unwrap();
        stream.write_all(b"configured listener").await.unwrap();
    });

    // A client can also be configured before connecting.
    let client_socket = TcpSocket::new_v4()?;
    client_socket.set_nodelay(true)?;
    let mut client = client_socket.connect(addr).await?;

    let mut text = String::new();
    client.read_to_string(&mut text).await?;
    println!("{text}");
    println!("client nodelay: {}", client.nodelay()?);
    server.await.unwrap();
    Ok(())
}
Output
reuseaddr: true
configured listener
client nodelay: true

TcpSocket also sets buffer sizes (set_send_buffer_size, set_recv_buffer_size), keepalive, and linger. Its connect takes a SocketAddr, not a host name, and consumes the socket.

Example 12: Connection refused

Connecting to a port where nothing listens fails with ErrorKind::ConnectionRefused. The program below creates a listener to get a free port, drops it, and connects to the now-closed port.

Rust
use std::io::ErrorKind;
use tokio::net::{TcpListener, TcpStream};

#[tokio::main]
async fn main() {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    drop(listener);

    let err = TcpStream::connect(addr).await.unwrap_err();
    println!("kind: {:?}", err.kind());
    println!("refused: {}", err.kind() == ErrorKind::ConnectionRefused);
}
Output
kind: ConnectionRefused
refused: true

A client that must survive a server restart retries on this error, usually with a delay between attempts.

Example 13: Connect timeouts

TcpStream::connect has no timeout parameter. If the address is unreachable and the network drops the packets, the attempt waits for the operating system's own connect timeout, which can be long. Wrap the call in tokio::time::timeout to set a limit. The wrapper below turns Elapsed into an io::Error of kind TimedOut, so callers handle one error type. The last part uses a future that never completes to show the timeout path without touching the network.

Rust
use std::future::Future;
use std::io::{self, ErrorKind};
use std::time::Duration;
use tokio::net::{TcpListener, TcpStream};
use tokio::time::timeout;

async fn with_timeout<T>(limit: Duration, op: impl Future<Output = io::Result<T>>) -> io::Result<T> {
    match timeout(limit, op).await {
        Ok(result) => result,
        Err(_elapsed) => Err(io::Error::new(ErrorKind::TimedOut, "connect timed out")),
    }
}

#[tokio::main]
async fn main() {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    let ok = with_timeout(Duration::from_secs(5), TcpStream::connect(addr)).await;
    println!("connect to a live listener: ok = {}", ok.is_ok());

    let stuck = std::future::pending::<io::Result<()>>();
    let err = with_timeout(Duration::from_millis(50), stuck).await.unwrap_err();
    println!("a connect that never finishes: {:?}", err.kind());
}
Output
connect to a live listener: ok = true
a connect that never finishes: TimedOut

The timeout covers the connect step only. Reads and writes need their own limits, otherwise a peer that stops sending holds the connection open forever.

Example 14: UnexpectedEof when the peer closes mid-message

A protocol that expects 8 bytes and receives 3 followed by EOF gets UnexpectedEof from read_exact. This is the normal signal for "the connection closed in the middle of a message".

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

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

    let server = tokio::spawn(async move {
        let (mut stream, _) = listener.accept().await.unwrap();
        let mut message = [0u8; 8];
        stream.read_exact(&mut message).await
    });

    let mut client = TcpStream::connect(addr).await?;
    client.write_all(b"abc").await?;
    drop(client);

    let err = server.await.unwrap().unwrap_err();
    println!("server read_exact: {:?}", err.kind());
    println!("unexpected eof: {}", err.kind() == ErrorKind::UnexpectedEof);
    Ok(())
}
Output
server read_exact: UnexpectedEof
unexpected eof: true

At a message boundary the same close is not an error: read returns Ok(0), and a lines loop ends with None. Whether EOF is an error depends on where in the protocol it arrives.

Example 15: ConnectionReset

A normal close sends a FIN and the peer sees EOF. An abortive close sends a RST (reset), and the peer's next read fails with ConnectionReset. Setting the linger time to zero before dropping a socket makes the operating system send a reset. Servers also see resets when a client process crashes or when a socket closes with unread data in its receive buffer.

Rust
use std::io::ErrorKind;
use tokio::io::AsyncReadExt;
use tokio::net::{TcpListener, TcpStream};

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

    let mut client = TcpStream::connect(addr).await?;
    let (server_side, _) = listener.accept().await?;

    server_side.set_zero_linger()?;
    drop(server_side);

    let mut buf = [0u8; 16];
    let err = client.read(&mut buf).await.unwrap_err();
    println!("client read: {:?}", err.kind());
    println!("connection reset: {}", err.kind() == ErrorKind::ConnectionReset);
    Ok(())
}
Output
client read: ConnectionReset
connection reset: true

Compare with Example 14: a plain drop gives EOF, a reset gives an error. set_zero_linger sets the linger time (SO_LINGER) to zero, so closing the socket discards unsent data and sends the reset at once.

Example 16: BrokenPipe when writing to a closed peer

Writing to a connection whose peer has closed fails after the peer's reset arrives. The first write after the close can still succeed, because the operating system accepts the data before it learns the peer is gone. Repeated writes eventually fail with BrokenPipe or ConnectionReset. A writer must expect either.

Rust
use std::io::ErrorKind;
use tokio::io::AsyncWriteExt;
use tokio::net::{TcpListener, TcpStream};

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

    let mut client = TcpStream::connect(addr).await?;
    let (server_side, _) = listener.accept().await?;
    drop(server_side);

    let mut writes = 0;
    let err = loop {
        match client.write_all(b"are you still there?").await {
            Ok(()) => writes += 1,
            Err(e) => break e,
        }
        tokio::task::yield_now().await;
    };

    println!("at least one write went through before the error: {}", writes >= 1);
    println!(
        "error is BrokenPipe or ConnectionReset: {}",
        matches!(err.kind(), ErrorKind::BrokenPipe | ErrorKind::ConnectionReset)
    );
    Ok(())
}
Output
at least one write went through before the error: true
error is BrokenPipe or ConnectionReset: true

The count of successful writes is not printed because it varies. A server treats both errors, and UnexpectedEof, as "the client went away": log at a low level, drop the connection, and continue.

Example 17: A line-based counter server with shared state

This server accepts several clients at once. Each client sends lines of numbers, and every handler adds them to one shared total. The shared total is an Arc<Mutex<u64>> from std::sync, locked for a moment and never held across an .await. Handlers read lines with BufReader and reply to each line with the running total. The clients run one after another so the running totals are the same on every run.

Rust
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};

async fn handle(stream: TcpStream, total: Arc<Mutex<u64>>) {
    let (reader, mut writer) = stream.into_split();
    let mut lines = BufReader::new(reader).lines();
    while let Some(line) = lines.next_line().await.unwrap() {
        let reply = match line.trim().parse::<u64>() {
            Ok(n) => {
                let mut guard = total.lock().unwrap();
                *guard += n;
                format!("total is {}\n", *guard)
            }
            Err(_) => "error: not a number\n".to_string(),
        };
        writer.write_all(reply.as_bytes()).await.unwrap();
    }
}

async fn run_client(addr: std::net::SocketAddr, numbers: &[&str]) -> Vec<String> {
    let stream = TcpStream::connect(addr).await.unwrap();
    let (reader, mut writer) = stream.into_split();
    let mut lines = BufReader::new(reader).lines();
    let mut replies = Vec::new();
    for n in numbers {
        writer.write_all(format!("{n}\n").as_bytes()).await.unwrap();
        replies.push(lines.next_line().await.unwrap().unwrap());
    }
    replies
}

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

    let server_total = Arc::clone(&total);
    tokio::spawn(async move {
        loop {
            let (stream, _) = listener.accept().await.unwrap();
            tokio::spawn(handle(stream, Arc::clone(&server_total)));
        }
    });

    for (name, numbers) in [("first", vec!["10", "20"]), ("second", vec!["5", "abc", "1"])] {
        let replies = run_client(addr, &numbers).await;
        println!("{name} client: {replies:?}");
    }
    println!("final total: {}", total.lock().unwrap());
    Ok(())
}
Output
first client: ["total is 10", "total is 30"]
second client: ["total is 35", "error: not a number", "total is 36"]
final total: 36

The server task loops forever; the program exits when main returns, and Tokio drops the remaining tasks at runtime shutdown. Each client waits for a reply to every line before sending the next, so the order of totals is fixed. Two clients sending at the same moment would interleave their additions in an order that depends on scheduling, though the final total would still be the same.

Example 18: copy_bidirectional for a forwarding proxy

tokio::io::copy_bidirectional(&mut a, &mut b) copies bytes from a to b and from b to a at the same time until both directions finish. When one side reaches EOF, it shuts down the write side of the other and keeps copying the remaining direction. It returns (a_to_b, b_to_a), the byte counts. If any read or write fails, it returns the error at once and data already read may be lost. This is the core of a TCP proxy.

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

#[tokio::main]
async fn main() -> io::Result<()> {
    // Origin: replies with the request in upper case.
    let origin = TcpListener::bind("127.0.0.1:0").await?;
    let origin_addr = origin.local_addr()?;
    tokio::spawn(async move {
        let (mut stream, _) = origin.accept().await.unwrap();
        let mut request = String::new();
        stream.read_to_string(&mut request).await.unwrap();
        stream.write_all(request.to_uppercase().as_bytes()).await.unwrap();
    });

    // Proxy: forwards one client connection to the origin.
    let proxy = TcpListener::bind("127.0.0.1:0").await?;
    let proxy_addr = proxy.local_addr()?;
    let proxy_task = tokio::spawn(async move {
        let (mut inbound, _) = proxy.accept().await.unwrap();
        let mut outbound = TcpStream::connect(origin_addr).await.unwrap();
        io::copy_bidirectional(&mut inbound, &mut outbound).await.unwrap()
    });

    // Client talks only to the proxy.
    let mut client = TcpStream::connect(proxy_addr).await?;
    client.write_all(b"through the proxy").await?;
    client.shutdown().await?;
    let mut reply = String::new();
    client.read_to_string(&mut reply).await?;
    println!("client got: {reply}");

    let (to_origin, to_client) = proxy_task.await.unwrap();
    println!("proxy forwarded {to_origin} bytes up and {to_client} bytes down");
    Ok(())
}
Output
client got: THROUGH THE PROXY
proxy forwarded 17 bytes up and 17 bytes down

The client's shutdown reached the proxy as EOF. The proxy passed the half-close to the origin, so the origin's read_to_string finished. The origin answered and dropped its connection, and the proxy passed that EOF back to the client. Without copy_bidirectional, this takes two copy tasks and careful shutdown handling.

Example 19: A request and response protocol with length prefixes

Lines suit text protocols. A binary protocol usually sends a length before each message. The server below reads a u32 length and then exactly that many bytes, using the read_u32 and read_exact methods from AsyncRead and AsyncWrite. It stops at a clean EOF between messages and treats EOF inside a message as an error.

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

async fn read_frame(stream: &mut TcpStream) -> std::io::Result<Option<Vec<u8>>> {
    let len = match stream.read_u32().await {
        Ok(len) => len as usize,
        Err(e) if e.kind() == ErrorKind::UnexpectedEof => return Ok(None),
        Err(e) => return Err(e),
    };
    let mut payload = vec![0u8; len];
    stream.read_exact(&mut payload).await?;
    Ok(Some(payload))
}

async fn write_frame(stream: &mut TcpStream, payload: &[u8]) -> std::io::Result<()> {
    stream.write_u32(payload.len() as u32).await?;
    stream.write_all(payload).await
}

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

    let server = tokio::spawn(async move {
        let (mut stream, _) = listener.accept().await.unwrap();
        let mut handled = 0;
        while let Some(frame) = read_frame(&mut stream).await.unwrap() {
            let mut reply = frame.clone();
            reply.reverse();
            write_frame(&mut stream, &reply).await.unwrap();
            handled += 1;
        }
        handled
    });

    let mut client = TcpStream::connect(addr).await?;
    for word in ["stressed", "drawer", "level"] {
        write_frame(&mut client, word.as_bytes()).await?;
        let reply = read_frame(&mut client).await?.unwrap();
        println!("{word} -> {}", String::from_utf8(reply).unwrap());
    }
    client.shutdown().await?;
    println!("server handled {} frames", server.await.unwrap());
    Ok(())
}
Output
stressed -> desserts
drawer -> reward
level -> level
server handled 3 frames

read_frame returns None when EOF arrives before the first byte of a frame (read_u32 fails with UnexpectedEof having read nothing). This version cannot tell that case from EOF after one to three bytes of the length. A production reader tracks how many bytes it has read, which Bytes and Framing does. It also caps the length, since a peer that sends 0xFFFFFFFF would otherwise make the server allocate 4 GiB.

Interview points

  • TcpListener::bind("127.0.0.1:0") picks a free port; read it with local_addr(). accept returns one TcpStream per connection, and the listener keeps accepting.
  • The accept loop is loop { accept; spawn }. A failed accept is logged and the loop continues. accept is cancel safe.
  • TCP is a byte stream. Read sizes do not match write sizes. Protocols need delimiters, length prefixes, or fixed sizes.
  • read returning 0 is EOF. read_exact gives UnexpectedEof if the stream ends first. A close at a message boundary is normal; inside a message it is an error.
  • shutdown closes the write direction only (half-close). Forgetting it causes hangs where each side waits for the other. Dropping an OwnedWriteHalf shuts down the write direction.
  • split borrows and stays in one task with no allocation. into_split allocates and moves across tasks. tokio::io::split works for any stream and locks internally.
  • ConnectionRefused means nothing listens. ConnectionReset means the peer sent a reset. BrokenPipe (or ConnectionReset) appears on writes after the peer closed. The first such write can succeed.
  • connect has no built-in timeout. Wrap it in tokio::time::timeout. Reads and writes need their own limits.
  • set_nodelay(true) disables Nagle's algorithm for small-message latency. Options are per connection.
  • TcpSocket sets options and the backlog before listen or connect. bind on TcpListener sets SO_REUSEADDR on Unix.
  • copy_bidirectional forwards both directions and propagates half-close. It returns the byte counts.
  • peek reads without consuming and returns only what has arrived.

Summary

  • TcpListener accepts connections and TcpStream carries one. Both use port 0 in tests so the operating system assigns a free port.
  • A server spawns one task per accepted connection and keeps the accept loop free.
  • TCP preserves byte order but not message boundaries. Read with loops, read_exact, or a framing layer.
  • Half-close with shutdown lets one side finish sending and still receive a reply.
  • Handle ConnectionRefused, ConnectionReset, BrokenPipe, and UnexpectedEof as normal events on a network.
  • Add explicit timeouts to connects and reads. Use TcpSocket for socket options and backlog, and copy_bidirectional to forward traffic.