UDP Loss, Duplicates and Reordering in C++
Numbering messages makes missing and out-of-order data observable. This example sends a controlled sequence and shows what the receiver can infer from it.
Before you start
Complete Datagram Boundaries and Buffer Sizes. Run from examples/network on the Pi. The program owns both sockets and finishes without a background server.
Outcome: recognize a gap, a duplicate and a late message, and explain why detecting them does not repair delivery.
Start with the picture
sequenceDiagram
accTitle: Detecting gaps, duplicates and a late message
accDescr: The fixture sends IDs 1, 3, 3 and 2. The receiver marks them as new, a gap, a duplicate and late. The final arrival fills the gap.
participant S as Sender fixture
participant R as Receiver
S->>R: ID 1
Note right of R: New
S->>R: ID 3
Note right of R: Gap: ID 2 missing
S->>R: ID 3 again
Note right of R: Duplicate
S->>R: ID 2
Note right of R: Late: gap filled
This time the sender deliberately changes the order and repeats an ID. It reproduces receiver conditions; it does not make Linux drop or reorder IP packets.
Build and run both cases
| cmake --build --preset native-debug --target udp_sequence
./build/native/debug/udp_sequence late
|
Expected output:
| id=1 new
id=3 gap
id=3 duplicate
id=2 late
missing=none
|
Now omit ID 2:
| ./build/native/debug/udp_sequence loss
|
Expected output:
| id=1 new
id=3 gap
id=3 duplicate
missing=2
|
Read the receiver's decisions
Complete file: app/udp-sequence/main.cpp. Read the direct sendto() → poll() → recvfrom() calls in the loop, then the sequence-number decisions. Both raw descriptors are closed explicitly at the bottom.
| #include <algorithm>
#include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <iostream>
#include <poll.h>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/socket.h>
#include <system_error>
#include <unistd.h>
#include <vector>
int main(int argc, char** argv) {
int sender = -1;
int receiver = -1;
int status = 0;
try {
const std::string_view mode = argc == 2 ? argv[1] : "late";
if (argc > 2 || (mode != "late" && mode != "loss")) {
throw std::invalid_argument("usage: udp_sequence [late|loss]");
}
const std::vector<int> fixture = mode == "late"
? std::vector<int>{1, 3, 3, 2}
: std::vector<int>{1, 3, 3};
sender =
::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
if (sender < 0) {
throw std::system_error(errno, std::generic_category(),
"socket sender");
}
receiver =
::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
if (receiver < 0) {
throw std::system_error(errno, std::generic_category(),
"socket receiver");
}
sockaddr_in target{};
target.sin_family = AF_INET;
target.sin_port = htons(0);
target.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (::bind(receiver, reinterpret_cast<const sockaddr*>(&target),
sizeof(target)) < 0) {
throw std::system_error(errno, std::generic_category(), "bind");
}
socklen_t target_size = sizeof(target);
if (::getsockname(receiver, reinterpret_cast<sockaddr*>(&target),
&target_size) < 0) {
throw std::system_error(errno, std::generic_category(),
"getsockname");
}
std::array<bool, 4> seen{};
int highest = 0;
for (const int id : fixture) {
// Schedule messages here, without changing the OS network.
const std::string message = std::to_string(id) + ":23.5";
const auto sent = ::sendto(
sender, message.data(), message.size(), MSG_DONTWAIT,
reinterpret_cast<const sockaddr*>(&target), sizeof(target));
if (sent < 0) {
throw std::system_error(errno, std::generic_category(),
"sendto");
}
if (static_cast<std::size_t>(sent) != message.size()) {
throw std::runtime_error("short datagram send");
}
pollfd input{receiver, POLLIN, 0};
const int ready = ::poll(&input, 1, 3000);
if (ready < 0) {
throw std::system_error(errno, std::generic_category(), "poll");
}
if (ready == 0) {
throw std::runtime_error("receive timeout");
}
if (!(input.revents & POLLIN)) {
throw std::runtime_error("socket error while waiting");
}
std::array<char, 64> buffer{};
const auto size = ::recvfrom(receiver, buffer.data(), buffer.size(),
MSG_TRUNC, nullptr, nullptr);
if (size < 0) {
throw std::system_error(errno, std::generic_category(),
"recvfrom");
}
if (static_cast<std::size_t>(size) > buffer.size()) {
throw std::runtime_error("datagram too large");
}
const std::string_view received(buffer.data(),
static_cast<std::size_t>(size));
const auto separator = received.find(':');
if (separator == std::string_view::npos) {
throw std::runtime_error("missing separator");
}
int sequence = 0;
const auto [end, error] = std::from_chars(
received.data(), received.data() + separator, sequence);
if (error != std::errc{} || end != received.data() + separator ||
sequence < 1 || sequence > 3) {
throw std::runtime_error("invalid sequence");
}
std::cout << "id=" << sequence;
if (seen[sequence]) {
std::cout << " duplicate";
} else if (sequence < highest) {
std::cout << " late";
} else if (sequence > highest + 1) {
std::cout << " gap";
} else {
std::cout << " new";
}
std::cout << '\n';
seen[sequence] = true;
highest = std::max(highest, sequence);
}
std::cout << "missing=" << (seen[2] ? "none" : "2") << '\n';
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
status = 1;
}
if (receiver >= 0) {
::close(receiver);
}
if (sender >= 0) {
::close(sender);
}
return status;
}
|
Each payload is text such as 3:23.5. The first field belongs to the application protocol; UDP does not supply a sequence number. Serializing text also avoids sending an in-memory C++ object's padding or native byte order.
The receiver validates the numeric ID before indexing seen. It checks duplicates first, then compares a new ID with the highest previously observed ID. ID 3 after ID 1 exposes a gap; ID 2 later fills it.
This deliberately small fixture accepts only IDs 1–3. It knows how many datagrams the test sends, so it can finish. A real receiver cannot infer that message 2 is permanently lost merely because message 3 arrived.
Decide what the application should do
For a display of the latest measurement, you might ignore duplicate and older readings. For an operation that must complete, you need acknowledgements, retry limits, timeouts and a way to avoid executing a repeated command twice. Numbering alone supplies none of these.
A larger sequence space also needs rules for wraparound, sessions, restarts and bounded history. The four-entry seen array is an educational fixture, not a general reliability layer.
Try a different schedule
In the late fixture, change {1, 3, 3, 2} to {1, 2, 2, 3}. Predict the output, rebuild, then compare. Restore the original fixture before running the exact-output tests.
The introductory comparison shows the next question: how does TCP hold later bytes until a gap is filled?
Check your understanding
Does id=3 gap prove that ID 2 will never arrive?
Compare your reasoning
No. It may arrive late, as it does in the first fixture. The application needs a deadline and an explicit policy for incomplete data.
Continue the course
Previous: Datagram Boundaries and Buffer Sizes · Course overview · Next: Your First TCP Connection
Reference: UDP delivery semantics.