Your First TCP Connection in C++
UDP sends separate datagrams; TCP connects two endpoints and carries an ordered stream of bytes. Build a local echo pair and follow the connection from creation to its final close.
Before you start
Complete the UDP lessons. Use two terminals on the Pi, both in the same examples/network directory.
Outcome: distinguish a listening socket from an accepted connection, exchange bytes through raw socket calls, and explain what a zero return from recv() means.
Follow the connection
sequenceDiagram
accTitle: A TCP echo connection from listen to EOF
accDescr: The server listens on port 9001. The client connects. Accept returns a connected descriptor. The server echoes incoming bytes. The client finishes sending with shutdown, continues receiving, and sees EOF when the server closes.
participant C as Client
participant S as Server
S->>S: socket, bind, listen
Note over S: Listener on 127.0.0.1:9001
C->>S: connect
S->>S: connection = accept(listener)
Note over S: Close listener for this one-client demo
loop For received bytes
C->>S: Request bytes
S->>C: Echo bytes
end
C->>S: shutdown(SHUT_WR)
Note over S: recv returns 0 after request bytes
S-->>C: Close connection
Note over C: Read remaining echo, then EOF
Linux handles the TCP handshake. accept() takes a queued connection and returns a new descriptor; it is not the handshake itself. The diagram shows application operations, not individual packets.
Both ends use IPv4 loopback. No LAN address, port forwarding or root access is needed.
Build and run
Build the two executables once:
| cmake --preset native-debug
cmake --build --preset native-debug --parallel 2
|
In terminal A:
| ./build/native/debug/tcp_echo_server 9001
|
It prints listening 127.0.0.1:9001. Within 30 seconds, run in terminal B:
| ./build/native/debug/tcp_echo_client 9001 hello
|
Client output:
Server output after the initial listening line:
| accepted connection
echoed bytes=5
|
The server handles one connection and exits. Restart it for each experiment. If the port is occupied, use another unprivileged port, such as 19001, in both commands.
The server: two descriptors, two jobs
Read listener and connection separately:
| Descriptor |
Created by |
Purpose |
listener |
socket() |
Bind an address and listen for connections. |
connection |
accept() |
Read and write this client's byte stream. |
Closing the listener does not close the accepted connection. This example closes the listener after accepting its single client.
Complete file: app/tcp-echo/server.cpp. All socket operations and cleanup are in this file.
| #include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <string_view>
#include <sys/socket.h>
#include <sys/time.h>
#include <system_error>
#include <unistd.h>
int main(int argc, char** argv) {
int listener = -1;
int connection = -1;
int status = 0;
try {
if (argc > 2) {
throw std::invalid_argument("usage: tcp_echo_server [port]");
}
unsigned int port = 9001;
if (argc == 2) {
const std::string_view text = argv[1];
const auto [end, error] =
std::from_chars(text.data(), text.data() + text.size(), port);
if (error != std::errc{} || end != text.data() + text.size() ||
port > 65535) {
throw std::invalid_argument("port must be 0..65535");
}
}
listener = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (listener < 0) {
throw std::system_error(errno, std::generic_category(), "socket");
}
const int reuse = 1;
if (::setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &reuse,
sizeof(reuse)) < 0) {
throw std::system_error(errno, std::generic_category(),
"setsockopt SO_REUSEADDR");
}
// Bound the blocking accept call without a background event loop.
const timeval accept_timeout{30, 0};
if (::setsockopt(listener, SOL_SOCKET, SO_RCVTIMEO, &accept_timeout,
sizeof(accept_timeout)) < 0) {
throw std::system_error(errno, std::generic_category(),
"setsockopt SO_RCVTIMEO");
}
sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(static_cast<std::uint16_t>(port));
local.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (::bind(listener, reinterpret_cast<const sockaddr*>(&local),
sizeof(local)) < 0) {
throw std::system_error(errno, std::generic_category(), "bind");
}
if (::listen(listener, 1) < 0) {
throw std::system_error(errno, std::generic_category(), "listen");
}
socklen_t local_size = sizeof(local);
if (::getsockname(listener, reinterpret_cast<sockaddr*>(&local),
&local_size) < 0) {
throw std::system_error(errno, std::generic_category(),
"getsockname");
}
std::cout << "listening 127.0.0.1:" << ntohs(local.sin_port)
<< std::endl;
// accept returns a different descriptor for the connected stream.
connection = ::accept(listener, nullptr, nullptr);
if (connection < 0) {
throw std::system_error(errno, std::generic_category(), "accept");
}
::close(listener);
listener = -1; // This server handles only this one connection.
std::cout << "accepted connection\n";
const timeval io_timeout{3, 0};
if (::setsockopt(connection, SOL_SOCKET, SO_RCVTIMEO, &io_timeout,
sizeof(io_timeout)) < 0 ||
::setsockopt(connection, SOL_SOCKET, SO_SNDTIMEO, &io_timeout,
sizeof(io_timeout)) < 0) {
throw std::system_error(errno, std::generic_category(),
"setsockopt I/O timeout");
}
std::array<char, 128> buffer{};
std::size_t total = 0;
while (true) {
const auto received =
::recv(connection, buffer.data(), buffer.size(), 0);
if (received < 0) {
throw std::system_error(errno, std::generic_category(), "recv");
}
if (received == 0) {
break; // Peer has finished sending: EOF, not an empty datagram.
}
const auto count = static_cast<std::size_t>(received);
if (total + count > 1024) {
throw std::runtime_error("request exceeds 1024 bytes");
}
std::size_t offset = 0;
while (offset < count) {
const auto sent = ::send(connection, buffer.data() + offset,
count - offset, MSG_NOSIGNAL);
if (sent < 0) {
throw std::system_error(errno, std::generic_category(),
"send");
}
if (sent == 0) {
throw std::runtime_error("send made no progress");
}
offset += static_cast<std::size_t>(sent);
}
total += count;
}
std::cout << "echoed bytes=" << total << '\n';
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
status = 1;
}
if (connection >= 0) {
::close(connection);
}
if (listener >= 0) {
::close(listener);
}
return status;
}
|
SOCK_STREAM selects TCP here. The receive loop reads up to 128 bytes at a time, and the inner send loop advances an offset by the number of bytes actually accepted. One receive need not correspond to one client send.
The request ends when the client stops its sending direction. Until then, the server echoes each chunk. Its 1024-byte request limit keeps this exercise small.
The client: finish sending, keep receiving
Complete file: app/tcp-echo/client.cpp.
| #include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/socket.h>
#include <sys/time.h>
#include <system_error>
#include <unistd.h>
int main(int argc, char** argv) {
int fd = -1;
int status = 0;
try {
if (argc < 2 || argc > 3) {
throw std::invalid_argument(
"usage: tcp_echo_client port [message]");
}
unsigned int port = 0;
const std::string_view text = argv[1];
const auto [end, error] =
std::from_chars(text.data(), text.data() + text.size(), port);
if (error != std::errc{} || end != text.data() + text.size() ||
port == 0 || port > 65535) {
throw std::invalid_argument("port must be 1..65535");
}
const std::string_view message = argc == 3 ? argv[2] : "hello";
if (message.size() > 1024) {
throw std::invalid_argument("message exceeds 1024 bytes");
}
fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd < 0) {
throw std::system_error(errno, std::generic_category(), "socket");
}
// On Linux these bound blocking receive, send and connect waits.
const timeval io_timeout{3, 0};
if (::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &io_timeout,
sizeof(io_timeout)) < 0 ||
::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &io_timeout,
sizeof(io_timeout)) < 0) {
throw std::system_error(errno, std::generic_category(),
"setsockopt I/O timeout");
}
sockaddr_in server{};
server.sin_family = AF_INET;
server.sin_port = htons(static_cast<std::uint16_t>(port));
server.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (::connect(fd, reinterpret_cast<const sockaddr*>(&server),
sizeof(server)) < 0) {
throw std::system_error(errno, std::generic_category(), "connect");
}
std::size_t offset = 0;
while (offset < message.size()) {
const auto sent = ::send(fd, message.data() + offset,
message.size() - offset, MSG_NOSIGNAL);
if (sent < 0) {
throw std::system_error(errno, std::generic_category(), "send");
}
if (sent == 0) {
throw std::runtime_error("send made no progress");
}
offset += static_cast<std::size_t>(sent);
}
// Finish this request, but keep the receiving direction open.
if (::shutdown(fd, SHUT_WR) < 0) {
throw std::system_error(errno, std::generic_category(), "shutdown");
}
std::array<char, 128> buffer{};
std::string reply;
while (true) {
const auto received = ::recv(fd, buffer.data(), buffer.size(), 0);
if (received < 0) {
throw std::system_error(errno, std::generic_category(), "recv");
}
if (received == 0) {
break;
}
const auto count = static_cast<std::size_t>(received);
if (reply.size() + count > message.size()) {
throw std::runtime_error("reply too large");
}
reply.append(buffer.data(), count);
}
if (reply != message) {
throw std::runtime_error("echo mismatch");
}
std::cout << "reply bytes=" << reply.size() << " data=" << reply
<< '\n';
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
status = 1;
}
if (fd >= 0) {
::close(fd);
}
return status;
}
|
connect() establishes the connection's peer. After that, send() and recv() use the connected descriptor without a destination argument on every call.
The first loop submits all request bytes. shutdown(fd, SHUT_WR) then finishes only the sending direction: the client can still receive the echo. Its receive loop continues until EOF and compares the complete result with the original input.
| Result |
What the code does |
send() > 0 |
Advance the offset by the returned byte count. |
recv() > 0 |
Process or append those bytes, then read again. |
recv() == 0 |
Stop reading: the peer's sending direction has ended. |
A call returns -1 |
Report the error and reach explicit descriptor cleanup. |
MSG_NOSIGNAL makes a broken-stream send report an error without terminating the process through SIGPIPE.
Why this exchange ends
This tiny protocol uses end of request to mean the client's write-side shutdown. It supports one request per connection. A persistent connection needs another way to delimit messages; that is the next lesson.
Unlike the earlier UDP examples, these are blocking sockets. Linux's SO_RCVTIMEO and SO_SNDTIMEO bound individual I/O waits: 30 seconds for accepting a client, then three seconds for connected I/O. These are per-call timeouts, not an overall request deadline. An interrupted or failed call is reported rather than retried indefinitely.
Compile the files directly
CMake compiles these same standalone files. To build without it:
| mkdir -p build/direct
c++ -std=c++20 -Wall -Wextra -Wpedantic \
app/tcp-echo/server.cpp -o build/direct/tcp_echo_server
c++ -std=c++20 -Wall -Wextra -Wpedantic \
app/tcp-echo/client.cpp -o build/direct/tcp_echo_client
|
Run build/direct/tcp_echo_server 9001 and build/direct/tcp_echo_client 9001 hello in separate terminals.
Change one condition
Restart the server and send an empty request:
| ./build/native/debug/tcp_echo_client 9001 ""
|
Expect reply bytes=0 data=. The client sends no payload bytes, then half-closes; this does not create an empty TCP datagram. The server reads EOF and closes its side.
Next, change a receive buffer from 128 bytes to one byte, rebuild, and send "hello Pi". The complete echo must still contain eight bytes. Buffer size changes how your code reads the stream, not the application's message meaning. Restore the original buffer before continuing.
Running the client after the server exits produces a connection error. If a connected peer remains silent, the I/O timeout produces an error rather than a successful empty reply.
Check your understanding
Why does the client use shutdown(SHUT_WR) instead of closing its descriptor immediately after sending?
Compare your reasoning
Closing the descriptor would prevent the client from reading its echo. SHUT_WR ends only the outgoing direction, letting the server detect EOF while the client keeps receiving. The final close releases the descriptor after the reply has been checked.
Continue the course
Previous: Loss, Duplicates and Reordering · Course overview · Next: Message Boundaries in a TCP Stream
References: TCP sockets, accept, send, receive, shutdown, Linux socket timeouts.