TCP Disconnection and Timeouts in C++
A peer can finish sending, reset a connection or simply stop responding. Observe each case, then set a deadline that expires even when bytes keep arriving.
Before you start
Complete Message Boundaries in a TCP Stream. Run the commands in two terminals on the Pi, both in examples/network.
Outcome: distinguish EOF from a reset and from a local receive deadline, without discarding unread bytes or restarting the time budget after every read.
These are one-client loopback experiments. No root access, firewall changes or permanent service is needed.
Three different ways to stop waiting
| Observation |
Meaning |
What it does not prove |
recv() > 0 |
These bytes are available now. |
The whole application response has arrived. |
recv() == 0 |
The peer has finished its sending direction, after queued bytes. |
The response is valid or complete. |
recv() == -1, errno == ECONNRESET |
The connection was reset. |
Previously unread bytes can still be recovered. |
| Our clock reaches the deadline |
Our application stops waiting. |
The peer is dead, or TCP has closed itself. |
A clean EOF can still truncate a length-prefixed message. Keep the previous lesson's parser checks; transport termination and application success are separate decisions.
sequenceDiagram
accTitle: EOF, reset and deadline are different outcomes
accDescr: The client sends a question mark to start the fixture. A clean peer sends hello then finishes its write side. A reset aborts the connection. A silent peer sends nothing, so the client's own clock ends the wait.
participant C as Client
participant S as Fixture server
C->>S: One question-mark byte
Note over C: Start receive deadline once
alt Clean end of stream
S->>C: hello
S-->>C: Finish sending
Note over C: Read queued bytes, then recv = 0
else Abortive close
S--xC: Reset connection
Note over C: recv fails with ECONNRESET
else No response before deadline
Note over S: Connection stays open
Note over C: Local clock expires, close socket
end
This is an application-level sequence, not a packet trace. EOF concerns one direction: shutdown(SHUT_WR) finishes sending without itself disabling reception.
Build and try the fixture server
| cmake --preset native-debug
cmake --build --preset native-debug --parallel 2
|
In terminal A, start the clean-EOF mode:
| ./build/native/debug/tcp_lifecycle_server 9002 eof
|
Within 30 seconds, run in terminal B:
| ./build/native/debug/tcp_lifecycle_client 9002
|
Expected client output:
The client uses a four-byte buffer, so it must read again to collect all five bytes. It continues until EOF rather than assuming the first read is the whole response.
Restart terminal A's server for each row below, replacing its last argument. Keep the same client command:
| Server mode |
Server behaviour after receiving ? |
Typical client output |
Exit code |
eof |
Send hello, finish sending, then close. |
eof bytes=5 data=hello |
0 |
reset |
Abort the accepted connection with Linux SO_LINGER={1,0}. |
reset bytes=0 data= |
2 |
idle |
Send nothing, keep open for about two seconds. |
deadline bytes=0 data= |
3 |
drip |
Send a, b, c about 400 ms apart, then wait. |
deadline bytes=3 data=abc |
3 |
For example, terminal A:
| ./build/native/debug/tcp_lifecycle_server 9002 drip
|
Terminal B:
| ./build/native/debug/tcp_lifecycle_client 9002
echo $?
|
The client normally stops after about one second of receiving, even in drip mode. Scheduling can change how many bytes it reads before that point; the invariant is that progress does not renew the deadline. These are not real-time timing guarantees.
The server handles one connection and exits. Wait for its finished mode=... line before restarting it. If port 9002 is occupied, choose another unprivileged port in both terminals.
Reset is a test fixture, not a cleanup recipe
The reset mode deliberately uses abortive close to reproduce a Linux TCP error. Do not add this option to normal cleanup: it can discard data. A crashed process does not necessarily produce a reset; ordinary descriptor closure can produce a clean EOF.
One receive deadline, not a timeout per read
Earlier samples used SO_RCVTIMEO to bound each blocking call. A peer sending some bytes before each call's timeout can keep a loop running far longer than that timeout.
Here, the client sets one deadline after connecting and sending the one-byte request:
sequenceDiagram
accTitle: Incoming bytes consume the same receive budget
accDescr: The default receive budget starts once at roughly zero milliseconds. Bytes a, b and c arrive around zero, four hundred and eight hundred milliseconds. The client stops around one thousand milliseconds, even though the server remains open.
participant S as Drip server
participant C as Client clock
Note over C: Start once, budget = 1000 ms
S->>C: a, around 0 ms
Note over C: Remaining budget decreases
S->>C: b, around 400 ms
S->>C: c, around 800 ms
Note over C: Around 1000 ms, deadline
Note over S: Still open, no EOF yet
Read the receive loop in this order:
steady_clock supplies a clock unaffected by wall-clock adjustments.
- Compute
deadline - now before every poll(); never replace deadline after progress.
- Round a positive remainder up to milliseconds for
poll(), then check the clock again when it returns.
- Read with
MSG_DONTWAIT. If readiness changes, EAGAIN or EWOULDBLOCK means try the same deadline again, not EOF.
- After
EINTR, recompute the remaining budget instead of starting a fresh wait.
POLLHUP may arrive alongside unread data. Do not exit immediately on that flag: read the queued bytes first and let recv() distinguish bytes, EOF and an error.
This budget covers only the receive phase, not DNS, connecting or sending. There is no DNS lookup here. Linux SO_SNDTIMEO separately bounds the blocking setup calls to about three seconds each; it does not set the poll() timeout. A whole-request deadline would need the same clock budget carried through all phases.
Read the standalone client
Complete file: app/tcp-lifecycle/client.cpp. All socket calls and descriptor cleanup are visible here. The one-byte request synchronizes the fixture so it does not reset the connection before the client has sent its request.
| #include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <poll.h>
#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 = 1;
try {
if (argc < 2 || argc > 3) {
throw std::invalid_argument(
"usage: tcp_lifecycle_client port [receive_ms]");
}
unsigned int port = 0;
const std::string_view port_text = argv[1];
const auto [port_end, port_error] = std::from_chars(
port_text.data(), port_text.data() + port_text.size(), port);
if (port_error != std::errc{} ||
port_end != port_text.data() + port_text.size() || port == 0 ||
port > 65535) {
throw std::invalid_argument("port must be 1..65535");
}
int receive_ms = 1000;
if (argc == 3) {
const std::string_view text = argv[2];
const auto [end, error] = std::from_chars(
text.data(), text.data() + text.size(), receive_ms);
if (error != std::errc{} || end != text.data() + text.size() ||
receive_ms < 1 || receive_ms > 10000) {
throw std::invalid_argument("receive_ms must be 1..10000");
}
}
fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd < 0) {
throw std::system_error(errno, std::generic_category(), "socket");
}
// Separate setup waits from the receive-phase deadline below.
const timeval setup_timeout{3, 0};
if (::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &setup_timeout,
sizeof(setup_timeout)) < 0) {
throw std::system_error(errno, std::generic_category(),
"setsockopt SO_SNDTIMEO");
}
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");
}
// The server waits for this one byte before choosing its behaviour.
const char request = '?';
const auto sent = ::send(fd, &request, 1, MSG_NOSIGNAL);
if (sent < 0) {
throw std::system_error(errno, std::generic_category(), "send");
}
if (sent != 1) {
throw std::runtime_error("request made no progress");
}
using Clock = std::chrono::steady_clock;
const auto deadline =
Clock::now() + std::chrono::milliseconds(receive_ms);
std::array<char, 4> buffer{};
std::string reply;
std::string_view outcome;
while (true) {
const auto remaining = std::chrono::ceil<std::chrono::milliseconds>(
deadline - Clock::now())
.count();
if (remaining <= 0) {
outcome = "deadline";
break;
}
pollfd ready{fd, POLLIN, 0};
const int result = ::poll(&ready, 1, static_cast<int>(remaining));
if (result < 0) {
if (errno == EINTR) {
// Keep the original deadline after an interruption.
continue;
}
throw std::system_error(errno, std::generic_category(), "poll");
}
if (result == 0 || Clock::now() >= deadline) {
continue; // Check the same deadline again.
}
if (ready.revents & POLLNVAL) {
throw std::runtime_error("invalid socket descriptor");
}
// A hangup can coexist with unread data. Check with recv.
const auto received =
::recv(fd, buffer.data(), buffer.size(), MSG_DONTWAIT);
if (received < 0) {
if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) {
continue;
}
if (errno == ECONNRESET) {
outcome = "reset";
break;
}
throw std::system_error(errno, std::generic_category(), "recv");
}
if (received == 0) {
outcome = "eof";
break;
}
if (reply.size() + static_cast<std::size_t>(received) > 1024) {
throw std::runtime_error("reply exceeds 1024 bytes");
}
reply.append(buffer.data(), static_cast<std::size_t>(received));
}
std::cout << outcome << " bytes=" << reply.size() << " data=" << reply
<< '\n';
status = outcome == "eof" ? 0 : outcome == "reset" ? 2 : 3;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
}
if (fd >= 0) {
::close(fd);
}
return status;
}
|
Exit code 1 is reserved for other errors, such as invalid arguments, connection refusal or an oversized reply. This diagnostic client treats EOF as a completed observation, not proof of a valid application response.
How the server creates each outcome
The server consumes exactly the one-byte ? request, then selects the requested behaviour. eof sends all five bytes with a partial-write loop. reset changes only the accepted socket's linger option before cleanup. idle and drip use sleeping waits, not CPU-spinning loops, and finish after about two seconds of fixture activity.
Full standalone fixture: app/tcp-lifecycle/server.cpp
| #include <arpa/inet.h>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <string_view>
#include <sys/socket.h>
#include <sys/time.h>
#include <system_error>
#include <thread>
#include <unistd.h>
int main(int argc, char** argv) {
int listener = -1;
int connection = -1;
int status = 0;
try {
if (argc != 3) {
throw std::invalid_argument(
"usage: tcp_lifecycle_server port eof|reset|idle|drip");
}
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 > 65535) {
throw std::invalid_argument("port must be 0..65535");
}
const std::string_view mode = argv[2];
if (mode != "eof" && mode != "reset" && mode != "idle" &&
mode != "drip") {
throw std::invalid_argument(
"mode must be eof, reset, idle or drip");
}
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;
const timeval accept_timeout{30, 0};
if (::setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &reuse,
sizeof(reuse)) < 0 ||
::setsockopt(listener, SOL_SOCKET, SO_RCVTIMEO, &accept_timeout,
sizeof(accept_timeout)) < 0) {
throw std::system_error(errno, std::generic_category(),
"setsockopt listener");
}
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 ||
::listen(listener, 1) < 0) {
throw std::system_error(errno, std::generic_category(),
"bind/listen");
}
socklen_t size = sizeof(local);
if (::getsockname(listener, reinterpret_cast<sockaddr*>(&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;
connection = ::accept(listener, nullptr, nullptr);
if (connection < 0) {
throw std::system_error(errno, std::generic_category(), "accept");
}
::close(listener);
listener = -1;
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");
}
char request = 0;
const auto received = ::recv(connection, &request, 1, 0);
if (received < 0) {
throw std::system_error(errno, std::generic_category(),
"recv request");
}
if (received != 1 || request != '?') {
throw std::runtime_error("expected one '?' request byte");
}
if (mode == "reset") {
// Linux test fixture: abort instead of sending FIN.
const linger abortive{1, 0};
if (::setsockopt(connection, SOL_SOCKET, SO_LINGER, &abortive,
sizeof(abortive)) < 0) {
throw std::system_error(errno, std::generic_category(),
"setsockopt SO_LINGER");
}
} else if (mode == "idle") {
std::this_thread::sleep_for(std::chrono::seconds(2));
} else {
const std::string_view reply = mode == "eof" ? "hello" : "abc";
std::size_t offset = 0;
while (offset < reply.size()) {
const auto count = mode == "drip" ? 1 : reply.size() - offset;
const auto sent = ::send(connection, reply.data() + offset,
count, 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);
if (mode == "drip") {
std::this_thread::sleep_for(std::chrono::milliseconds(400));
}
}
if (mode == "drip") {
std::this_thread::sleep_for(std::chrono::milliseconds(800));
} else if (::shutdown(connection, SHUT_WR) < 0) {
throw std::system_error(errno, std::generic_category(),
"shutdown");
}
}
std::cout << "finished mode=" << mode << '\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;
}
|
The listener waits at most about 30 seconds for a client. The accepted socket's three-second per-call I/O timeouts prevent a client that never sends ? from leaving the fixture waiting indefinitely. Interrupted setup calls are reported as errors; the receive-deadline loop in the client handles interruptions separately.
To compile without CMake:
| mkdir -p build/direct
c++ -std=c++20 -Wall -Wextra -Wpedantic -pthread \
app/tcp-lifecycle/server.cpp -o build/direct/tcp_lifecycle_server
c++ -std=c++20 -Wall -Wextra -Wpedantic \
app/tcp-lifecycle/client.cpp -o build/direct/tcp_lifecycle_client
|
Run the direct-build pair with the same arguments. -pthread selects platform thread support for the fixture's sleeping waits.
Change the budget, not the protocol
Restart the drip server, then allow three seconds for receiving:
| ./build/native/debug/tcp_lifecycle_client 9002 3000
|
It should now reach the server's clean close and print:
The optional argument is a receive budget in milliseconds, from 1 to 10000. A longer budget changes how long this client waits, not the server's behaviour.
A deadline does not generate a special TCP timeout message. This program chooses to close its own descriptor afterwards. Do not blindly resend a real request: a peer may have performed the operation before the reply was delayed, and a retry could duplicate it.
Troubleshooting
- Connection refused: start the server first and use the same port in both terminals. This is a connection-establishment failure, not a receive deadline.
- Server reports an invalid request: use
tcp_lifecycle_client, not the previous echo or framing client; their wire formats are different.
- Deadline with some data: expected for
drip; do not treat a partial reply as a complete message.
- Different byte count under load: inspect the outcome first. The example does not promise exact read timing or a fixed number of TCP packets.
Check your understanding
A peer sends one byte every 400 ms forever. Would a one-second timeout on each receive call guarantee that the entire receive loop finishes within one second?
Compare your reasoning
No. Each successful call could be followed by another one-second wait. A fixed deadline makes the remaining budget shrink despite incoming bytes. The deadline is a local policy; it does not tell you whether a remote operation succeeded.
Continue the course
Previous: Message Boundaries in a TCP Stream · Next: Multiple TCP Connections · Course overview
References: receive results and errors, poll readiness and hangups, Linux socket timeouts, TCP connection errors.