TCP clients and servers
A TCP connection is two streams with an address at each end. The standard library gives the listener, the stream and the buffered reader, and every program below uses both ends on one machine so that the output is the same on every run.
This lesson connects a client to a listener on an ephemeral port, reads a request line, and answers with a minimal HTTP response. The last section is the compiler refusing a line method on the socket itself.
A listener and a client
TcpListener::bind takes an address and, with port zero, lets the operating system choose one. local_addr reads it back, and the client connects to that address, which is what makes the two ends of this program agree without a fixed port.
The server thread accepts one connection and writes five bytes. The client reads exactly five and prints them.
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn main() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
stream.write_all(b"hello").unwrap();
});
let mut client = TcpStream::connect(address).unwrap();
let mut buffer = [0u8; 5];
client.read_exact(&mut buffer).unwrap();
println!("{}", String::from_utf8_lossy(&buffer));
server.join().unwrap();
}
rustc --edition 2024 pair.rs
./pair
hello
read_exact waits for the whole buffer and returns an error if the connection closes early, which is the difference from a single read that may return less. The server thread owns the listener, which is why the spawn takes it by move. The lesson on threads is the spawn.
A server that accepts more than one connection loops over incoming and moves each stream to a thread of its own, which is the pool of a later lesson with one job per connection. The listener stays in the main thread, and the loop ends when the accept call returns an error that is not about a single connection.
A request line
A protocol with text lines is read through a BufReader, which keeps the bytes it read ahead in its own buffer. read_line stops at the newline, and the line keeps it, so a print uses trim_end.
The client sends an HTTP request line and the empty line after it. The server reads the first line and prints it without the carriage return.
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn main() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
println!("request: {}", line.trim_end());
});
let mut client = TcpStream::connect(address).unwrap();
client.write_all(b"GET /index.html HTTP/1.1\r\n\r\n").unwrap();
server.join().unwrap();
}
rustc --edition 2024 request.rs
./request
request: GET /index.html HTTP/1.1
The three parts of the line are the method, the path and the version, and a real server splits them with split_whitespace. The BufReader is dropped with the stream at the end of the closure, which closes the connection. The lesson on text files reads a file the same way.
A server that must not wait forever sets a read timeout with set_read_timeout; the next read then returns an error of kind WouldBlock or TimedOut instead of blocking. A client that connects to a name rather than an address passes it to TcpStream::connect, which resolves it through ToSocketAddrs and tries each address it gets back. Both are one method on the stream and neither changes the shape of the program above.
A tiny HTTP answer
An HTTP response is a status line, a header with the length of the body, an empty line and the body. The client reads the status line through its own buffered reader and prints it.
The server answers every request with the two bytes ok. The client sends a request and prints the first line it gets back.
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn main() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = String::new();
{
let mut reader = BufReader::new(&stream);
reader.read_line(&mut request).unwrap();
}
let body = "ok";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).unwrap();
});
let mut client = TcpStream::connect(address).unwrap();
client.write_all(b"GET / HTTP/1.1\r\n\r\n").unwrap();
let mut reader = BufReader::new(&client);
let mut status = String::new();
reader.read_line(&mut status).unwrap();
println!("{}", status.trim_end());
server.join().unwrap();
}
rustc --edition 2024 http.rs
./http
HTTP/1.1 200 OK
The reader borrows the stream because the stream is still needed for the write, and the block ends the borrow before it. The header tells the client how many bytes to expect, which is what lets a real client stop reading; this one stops after the status line. A production server adds a thread per connection, the pool for the work and the queue between them.
A server that must not wait forever sets a read timeout with set_read_timeout; the next read then returns an error of kind WouldBlock or TimedOut instead of blocking. A client that connects to a name rather than an address passes it to TcpStream::connect, which resolves it through ToSocketAddrs.
The port in the address is the one the operating system chose, and a test prints it only when it needs to be read by a person.
read_line on a socket
read_line belongs to BufRead and not to TcpStream, so the call on the stream is a missing method. The compiler reports E0599 and the fix is the buffered reader of the previous section.
The program tries to read a line directly from the socket. The method does not exist on the type.
use std::net::TcpStream;
fn main() {
let mut stream = TcpStream::connect("127.0.0.1:9").unwrap();
let mut line = String::new();
stream.read_line(&mut line).unwrap();
println!("{line}");
}
rustc --edition 2024 readline_err.rs
error[E0599]: no method named `read_line` found for struct `TcpStream` in the current scope
--> readline_err.rs:7:12
|
7 | stream.read_line(&mut line).unwrap();
| ^^^^^^^^^ method not found in `TcpStream`
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0599`.
The method needs a buffer that remembers what it read, and a socket has none. Wrapping the stream in a BufReader is a line of code and the reason the error exists: a line-at-a-time read on an unbuffered socket would be one system call per byte. The next lesson reads the output of another program in the same way.
Exercises
- Answer with a 404 status and a body for a path other than /.
- Accept two connections in a loop and print the request line of each.
- Read the body with the length from the header and print it.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What does binding port zero ask the operating system to do?
Answer
Choose a free port
The kernel picks an unused port, and local_addr reports which one.
-
What is the difference between
readandread_exact?Answer
read may return fewer bytes, read_exact fills the buffer or fails
A single read returns what is available, and the exact form loops until the buffer is full.
-
Why is a
BufReaderused to read a line?Answer
It buffers the bytes and provides read_line
The line method belongs to BufRead, and the buffer avoids one call per byte.
-
What does
TcpListener::acceptreturn?Answer
A stream and the address of the peer
The pair is the connection and the address it came from.
-
What does this program print?
use std::net::TcpListener; fn main() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); println!("{}", address.ip()); }Answer
127.0.0.1
The address is the one that was bound, and the port is chosen but the IP is fixed.
-
What does this program print?
fn main() { let request = "GET /index.html HTTP/1.1\r\n"; println!("{}", request.trim_end()); }Answer
GET /index.html HTTP/1.1
trim_end removes the carriage return and the newline, and stops at the version.
-
What does this program print?
fn main() { let body = "ok"; let response = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}", body.len(), body); println!("{}", response.lines().count()); }Answer
4
The status line, the header, the empty line and the body are four lines.
-
What does this program print?
use std::net::TcpListener; fn main() { let listener = TcpListener::bind("127.0.0.1:0"); println!("{}", listener.is_ok()); }Answer
true
A local port on the loopback interface is available, so the bind succeeds.
-
What is the first line of the compiler output for this program?
use std::net::TcpStream; fn main() { let mut stream = TcpStream::connect("127.0.0.1:9").unwrap(); let mut line = String::new(); stream.read_line(&mut line).unwrap(); println!("{line}"); }Answer
error[E0599]: no method named
read_linefound for structTcpStreamin the current scopeThe line method needs a buffered reader, and the socket does not have it.
-
What is the first line of the compiler output for this program?
use std::net::TcpListener; fn main() { let listener = TcpListener::bind("127.0.0.1:8080").unwrap(); let (stream, address) = listener.accept().unwrap(); println!("{}", stream); }Answer
error[E0277]:
TcpStreamdoesn't implementstd::fmt::DisplayA stream has no printable form, so the format macro reports the missing trait.
The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev