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

  1. Answer with a 404 status and a body for a path other than /.
  2. Accept two connections in a loop and print the request line of each.
  3. 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.

  1. 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.

  2. What is the difference between read and read_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.

  3. Why is a BufReader used 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.

  4. What does TcpListener::accept return?

    Answer

    A stream and the address of the peer

    The pair is the connection and the address it came from.

  5. 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.

  6. 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.

  7. 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.

  8. 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.

  9. 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_line found for struct TcpStream in the current scope

    The line method needs a buffered reader, and the socket does not have it.

  10. 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]: TcpStream doesn't implement std::fmt::Display

    A 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

Banner image