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.
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(())
}client received: hello from the serverbind 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.
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(())
}port was assigned: true
client peer is the listener: true
server sees the client's address: true
accepted socket is on the listen address: trueThe 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.
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(())
}server assembled: abcdThe 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.
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(())
}client 1 says hi
client 2 says hi
client 3 says hi
server echoed 48 bytesEach 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.
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(())
}connections served: 2accept 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.
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(())
}received 41 bytesWithout 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.
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(())
}SHOUT THISExample 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.
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(())
}1: red
2: green
3: blueThe 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.
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(())
}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.
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(())
}default nodelay: false
after set_nodelay(true): trueSet 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.)
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(())
}reuseaddr: true
configured listener
client nodelay: trueTcpSocket 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.
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);
}kind: ConnectionRefused
refused: trueA 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.
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());
}connect to a live listener: ok = true
a connect that never finishes: TimedOutThe 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".
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(())
}server read_exact: UnexpectedEof
unexpected eof: trueAt 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.
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(())
}client read: ConnectionReset
connection reset: trueCompare 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.
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(())
}at least one write went through before the error: true
error is BrokenPipe or ConnectionReset: trueThe 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.
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(())
}first client: ["total is 10", "total is 30"]
second client: ["total is 35", "error: not a number", "total is 36"]
final total: 36The 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.
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(())
}client got: THROUGH THE PROXY
proxy forwarded 17 bytes up and 17 bytes downThe 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.
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(())
}stressed -> desserts
drawer -> reward
level -> level
server handled 3 framesread_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 withlocal_addr().acceptreturns oneTcpStreamper connection, and the listener keeps accepting.- The accept loop is
loop { accept; spawn }. A failedacceptis logged and the loop continues.acceptis cancel safe. - TCP is a byte stream. Read sizes do not match write sizes. Protocols need delimiters, length prefixes, or fixed sizes.
readreturning 0 is EOF.read_exactgivesUnexpectedEofif the stream ends first. A close at a message boundary is normal; inside a message it is an error.shutdowncloses the write direction only (half-close). Forgetting it causes hangs where each side waits for the other. Dropping anOwnedWriteHalfshuts down the write direction.splitborrows and stays in one task with no allocation.into_splitallocates and moves across tasks.tokio::io::splitworks for any stream and locks internally.ConnectionRefusedmeans nothing listens.ConnectionResetmeans the peer sent a reset.BrokenPipe(orConnectionReset) appears on writes after the peer closed. The first such write can succeed.connecthas no built-in timeout. Wrap it intokio::time::timeout. Reads and writes need their own limits.set_nodelay(true)disables Nagle's algorithm for small-message latency. Options are per connection.TcpSocketsets options and the backlog beforelistenorconnect.bindonTcpListenersetsSO_REUSEADDRon Unix.copy_bidirectionalforwards both directions and propagates half-close. It returns the byte counts.peekreads without consuming and returns only what has arrived.
Summary
TcpListeneraccepts connections andTcpStreamcarries one. Both use port0in 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
shutdownlets one side finish sending and still receive a reply. - Handle
ConnectionRefused,ConnectionReset,BrokenPipe, andUnexpectedEofas normal events on a network. - Add explicit timeouts to connects and reads. Use
TcpSocketfor socket options and backlog, andcopy_bidirectionalto forward traffic.
