Skip to content

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:

1
2
3
4
5
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:

1
2
3
4
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.

Donate