C++ Module 20 – Systems programming with C++
TCP sockets in C++: client and server on localhost
Write a TCP server and client in C++ with POSIX sockets on 127.0.0.1, give every socket an RAII owner and frame messages on a byte stream.
What you will learn
- Write a TCP server and client with POSIX sockets
- Design framing for messages on a byte stream
- Limit network experiments to the loopback address
Before you start
What you’ll learn · Before you start
What you will learn
- Write a TCP server and client with POSIX sockets
- Design framing for messages on a byte stream
- Limit network experiments to the loopback address
Before you start
On this page
A TCP server in C++ is a handful of POSIX calls. socket creates an endpoint, bind gives it an address and a port,
listen lets it take connections, and accept waits for a client and returns a new socket for that one connection. A
client calls socket and then connect. After that, both sides move bytes with send and recv, and close ends
the connection. Bound to 127.0.0.1, the loopback address, the server can be reached only from the same machine,
which is exactly what experiments and tests need.
Two facts shape the rest. TCP carries a stream of bytes, not of messages, so every protocol on top of it needs framing to mark where one message ends. And every call can fail or move fewer bytes than asked, so each socket gets an owner that closes it and each transfer gets a loop. This lesson runs every server and client on the loopback interface of one Mac, on both compilers; nothing reaches another machine.
The calls, in order
The calls of a TCP server and client, in order
Text description of the diagram
A sequence diagram with two lifelines, the server on the left and the client on the right, read from top to bottom.
- The server calls socket(), then bind() with the address 127.0.0.1 and port 0, so the system picks a free port, then listen().
- The server calls accept(), which waits until a client connects.
- The client calls socket() and then connect(). An arrow to the server marks the TCP handshake, which happens inside connect().
- On the server, accept() returns a new socket for this one client; the listening socket stays open for the next one.
- The client sends "hello".
- The server receives it with recv() and sends back "HELLO".
- The client calls shutdown(SHUT_WR): it will send nothing more, so the server's next recv() returns 0.
- The server closes the connection with close(), and the client's next recv() returns 0.
A socket is a file descriptor, so the move-only FileDescriptor from the previous lesson owns it. net.hpp wraps
the calls in four helpers: listen_on_loopback (socket, bind and listen), connect_to_loopback, send_all, which
loops until everything is sent, and recv_exactly, which loops until it has as many bytes as asked for.
C++ header · net.hpp
// net.hpp: TCP sockets on the loopback address 127.0.0.1, each owned by a FileDescriptor (fd.hpp).
#pragma once
#include "fd.hpp"
#include <cstdint>
#include <string>
#include <string_view>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
// The address 127.0.0.1:port. The loopback interface never leaves this machine.
inline sockaddr_in loopback(std::uint16_t port) {
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(port); // network byte order: most significant byte first
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 127.0.0.1
return addr;
}
// socket, bind and listen. Port 0 asks the system for any free port; local_port() says which one it chose.
inline std::expected<FileDescriptor, std::error_code> listen_on_loopback(std::uint16_t port = 0, int backlog = 16) {
FileDescriptor s(::socket(AF_INET, SOCK_STREAM, 0));
if (!s) return std::unexpected(last_error());
int on = 1;
::setsockopt(s.get(), SOL_SOCKET, SO_REUSEADDR, &on, sizeof on);
sockaddr_in addr = loopback(port);
if (::bind(s.get(), reinterpret_cast<const sockaddr*>(&addr), sizeof addr) != 0) return std::unexpected(last_error());
if (::listen(s.get(), backlog) != 0) return std::unexpected(last_error());
return s;
}
inline std::uint16_t local_port(int fd) {
sockaddr_in addr{};
socklen_t len = sizeof addr;
::getsockname(fd, reinterpret_cast<sockaddr*>(&addr), &len);
return ntohs(addr.sin_port);
}
inline std::expected<FileDescriptor, std::error_code> connect_to_loopback(std::uint16_t port) {
FileDescriptor s(::socket(AF_INET, SOCK_STREAM, 0));
if (!s) return std::unexpected(last_error());
sockaddr_in addr = loopback(port);
if (::connect(s.get(), reinterpret_cast<const sockaddr*>(&addr), sizeof addr) != 0) return std::unexpected(last_error());
return s;
}
// send() may send only part of the data: keep going. MSG_NOSIGNAL turns a write to a closed connection into the
// error EPIPE instead of the signal SIGPIPE, which would end the program.
inline std::error_code send_all(int fd, std::string_view data) {
while (!data.empty()) {
ssize_t n = ::send(fd, data.data(), data.size(), MSG_NOSIGNAL);
if (n < 0) {
if (errno == EINTR) continue;
return last_error();
}
data.remove_prefix(static_cast<std::size_t>(n));
}
return {};
}
// Reads exactly n bytes. Returns fewer only if the other side closed the connection first.
inline std::expected<std::string, std::error_code> recv_exactly(int fd, std::size_t n) {
std::string data(n, '\0');
std::size_t got = 0;
while (got < n) {
ssize_t r = ::recv(fd, data.data() + got, n - got, 0);
if (r == 0) break; // the connection was closed
if (r < 0) {
if (errno == EINTR) continue;
return std::unexpected(last_error());
}
got += static_cast<std::size_t>(r);
}
data.resize(got);
return data;
} C++ · echo.cpp
// echo.cpp: a TCP server and a client in one program, talking over 127.0.0.1.
#include "net.hpp"
#include <cctype>
#include <format>
#include <print>
#include <string>
#include <thread>
#include <vector>
int main() {
auto listener = listen_on_loopback(); // socket, bind to 127.0.0.1 with port 0, listen
if (!listener) {
std::println("listen: {}", listener.error().message());
return 1;
}
std::uint16_t port = local_port(listener->get());
std::vector<std::string> log; // what the server saw, printed at the end
std::jthread server([&] {
// accept waits for a client and returns a new socket for that one connection.
FileDescriptor conn(::accept(listener->get(), nullptr, nullptr));
log.push_back("accepted a connection");
std::size_t total = 0;
char buffer[1024];
for (ssize_t n; (n = ::recv(conn.get(), buffer, sizeof buffer, 0)) > 0;) {
for (ssize_t i = 0; i < n; ++i) buffer[i] = static_cast<char>(std::toupper(static_cast<unsigned char>(buffer[i])));
(void)send_all(conn.get(), std::string_view(buffer, static_cast<std::size_t>(n)));
total += static_cast<std::size_t>(n);
}
log.push_back(std::format("recv() returned 0 after {} bytes, so the client has finished sending", total));
}); // conn is closed when the thread's function returns
auto client = connect_to_loopback(port); // the TCP handshake happens inside connect
if (!client) {
std::println("connect: {}", client.error().message());
return 1;
}
for (std::string_view message : {"hello", "sockets on localhost"}) {
(void)send_all(client->get(), message);
auto reply = recv_exactly(client->get(), message.size());
std::println("client sent {:?}, got back {:?}", message, reply.value_or("?"));
}
::shutdown(client->get(), SHUT_WR); // no more data from the client: the server's recv() returns 0
auto rest = recv_exactly(client->get(), 1);
std::println("client: recv() returned {} bytes, so the server has closed the connection", rest.value_or("?").size());
server.join();
for (const std::string& line : log) std::println("server: {}", line);
std::println("port chosen by the system: {}", port != 0 ? "a free one, different on every run" : "none");
} An echo server and client on 127.0.0.1
from session import run
run("""
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined echo.cpp -o echo && ./echo
g++-16 -std=c++23 -Wall -Wextra -fsanitize=address,undefined echo.cpp -o echo-gcc && ./echo-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined echo.cpp -o echo && ./echo client sent "hello", got back "HELLO" client sent "sockets on localhost", got back "SOCKETS ON LOCALHOST" client: recv() returned 0 bytes, so the server has closed the connection server: accepted a connection server: recv() returned 0 after 25 bytes, so the client has finished sending port chosen by the system: a free one, different on every run $ g++-16 -std=c++23 -Wall -Wextra -fsanitize=address,undefined echo.cpp -o echo-gcc && ./echo-gcc client sent "hello", got back "HELLO" client sent "sockets on localhost", got back "SOCKETS ON LOCALHOST" client: recv() returned 0 bytes, so the server has closed the connection server: accepted a connection server: recv() returned 0 after 25 bytes, so the client has finished sending port chosen by the system: a free one, different on every run
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 echo_session.py
The server runs in a std::jthread and the client in main, in one process, which keeps the run self-contained. Port
0 asks the system for any unused port, and getsockname tells the client which one it got. The client sends two
messages and reads back exactly as many bytes as it sent, because a single recv may return less. Then it calls
shutdown(SHUT_WR): it will send nothing more, so the server’s recv returns 0, the server closes its socket, and
the client’s next recv returns 0 in turn. recv returning 0 is how both sides learn that the other has finished.
The listening socket stays open the whole time, ready to accept the next client while the first one is served.
Port numbers and addresses travel in network byte order, most significant byte first, which is why htons and
htonl convert them, while this Mac’s arm64 processor stores numbers least significant byte first.
Localhost only, on purpose
Bind experiments to 127.0.0.1
INADDR_LOOPBACK is 127.0.0.1; INADDR_ANY, written 0.0.0.0, means every network interface of the machine.
A test server bound to the second can be reached by other computers on the same network, a café’s or an office’s
included, unless a firewall stops them. Bind to the loopback address unless the program really must be reached
from elsewhere.
The loopback interface delivers packets inside the machine without touching a network card. RFC 1122 reserves all of
127.0.0.0/8 for it, and packets for those addresses never appear on a network. Port 0 is the right default for
tests as well: two test runs at the same time can never fight over one fixed port.
A stream of bytes, not of messages
TCP guarantees that the bytes arrive in order and without gaps, and says nothing about where one message ends. Two
send calls can arrive in one recv, and one send can arrive in several, whatever sizes the sender used. On the loopback interface, small messages
usually arrive exactly as they were sent, so code that treats one recv as one message passes every local test and
fails on a real network. A protocol therefore marks its message boundaries itself, in one of four common ways:
- A length prefix: a fixed-size number before each message says how many bytes follow.
- A delimiter: a byte that cannot occur inside a message, such as a newline, ends each one.
- A fixed size: every message has the same length, as in some binary protocols.
- Self-describing headers: HTTP/1.1 sends header lines, and
Content-Lengthsays how long the body is.
Check yourself
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Length-prefixed framing, with merged and split messages reproduced Pro: opens with a Pro, Premium or Ultimate pass
Framing bugs hide on localhost, where small messages arrive whole. This part forces two messages into one recv and
one message into many, then builds a frame reader that survives both and refuses frames that would exhaust memory.
In this part:
- Merged and split messages, reproduced on purpose
- A frame reader that survives both
- Seven ways a stream can cut a frame, tested
- Choosing the length field: size, byte order and limits
5 runnable examples (C++, C++ header, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Refused, in use, TIME_WAIT and SIGPIPE reproduced; then a key-value server Pro: opens with a Pro, Premium or Ultimate pass
Four failures come up in almost every program that uses sockets. Each is reproduced here on 127.0.0.1 with the reason and the fix, and the part ends with an exercise: a small key-value server that speaks length-prefixed frames.
In this part:
- Four failures every socket program meets
- Your turn: a key-value server on 127.0.0.1
4 runnable examples (C++, C++ header, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
Why should a development or test server bind to 127.0.0.1 rather than 0.0.0.0?
127.0.0.1 is the loopback address: packets to it never leave the machine, so only programs on the same computer can connect. 0.0.0.0, INADDR_ANY, means every network interface, so a server bound to it can be reached by other computers on the same network, such as a café's or an office's Wi-Fi, unless a firewall stops them. A server under development usually has no authentication and may expose debug endpoints, so binding to the loopback address keeps it private by default. Tests should also bind to port 0, which asks the system for any unused port, so that two test runs never compete for one fixed port. A server that must be reachable from other machines binds to a specific interface address or to 0.0.0.0 on purpose, with authentication in place.
-
Mid to senior
A C++ service and its client exchange JSON messages over TCP. Every test on one machine passes, but in production some messages arrive merged with the next one or cut in half. Explain why, and design the fix and the tests that prove it.
-
Mid to senior
A C++ server disappears a few times a day without a log line or a core dump, usually when clients on mobile networks disconnect in the middle of a large reply. Its exit status is 141. What happened, and how do you fix it?
-
Mid to senior
Your server reads a 4-byte length and then that many bytes of payload. A security review says that a single client can take the server down. Name at least three ways it can, and design the defences.
Key takeaways
- A server calls
socket,bind,listenandaccept; a client callssocketandconnect; both thensendandrecvand finallyclose. Each socket is a file descriptor with one owner. - Bind experiments and tests to
127.0.0.1, with port 0 when any unused port will do. recvreturning 0 means the other side has finished sending;shutdown(SHUT_WR)says so without closing.- TCP keeps the order of bytes but not the boundaries of messages, so every protocol frames them: a length prefix, a delimiter, a fixed size or self-describing headers.
References
- The Open Group Base Specifications Issue 8 (POSIX.1-2024) (The Open Group)
- socket: create an endpoint for communication (The Open Group)
- bind: bind a name to a socket (The Open Group)
- listen: listen for socket connections (The Open Group)
- accept, accept4: accept a new connection on a socket (The Open Group)
- connect: connect a socket (The Open Group)
- send: send a message on a socket (The Open Group)
- recv: receive a message from a connected socket (The Open Group)
- shutdown: shut down socket send and receive operations (The Open Group)
- signal.h: signals and their default actions (SIGPIPE) (The Open Group)
- netinet/in.h: Internet address family (INADDR_LOOPBACK, INADDR_ANY) (The Open Group)
- RFC 9293: Transmission Control Protocol (TCP) (IETF)
- RFC 1122: Requirements for Internet Hosts, Communication Layers (127.0.0.0/8 loopback) (IETF)
- RFC 9112: HTTP/1.1 (message body length) (IETF)
- tcp(7): TCP protocol (Linux man-pages: TCP_NODELAY, TIME_WAIT) (Linux man-pages project)
- socket(7): Linux socket interface (SO_REUSEADDR, SO_RCVTIMEO) (Linux man-pages project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress