Skip to content

UDP Datagram Boundaries and Buffer Sizes in C++

A large receive buffer does not combine separate UDP messages. A small buffer can discard part of one message; this experiment makes both behaviours visible.

Before you start

Complete the UDP echo exchange. This time one process owns two sockets, so only one terminal is needed. Run from examples/network on the Pi.

Outcome: predict three receives and distinguish an empty datagram from a closed stream.

Predict the result

sequenceDiagram accTitle: Three datagrams and a three-byte receive buffer accDescr: The sender queues A, BCDEFG and an empty datagram. The receiver reads A, then BCD while EFG is discarded, then the empty datagram. Separate messages are not combined. participant S as Sender participant R as Receiver S->>R: A (1 byte) S->>R: BCDEFG (6 bytes) S->>R: Empty (0 bytes) Note over R: 3-byte receive buffer R->>R: Read A R->>R: Read BCD, discard EFG R->>R: Read empty datagram

Why does the first receive return only A, and why can a later read not recover EFG?

Build and run

cmake --build --preset native-debug --target udp_boundaries
./build/native/debug/udp_boundaries

Expected output:

1
2
3
datagram=1 bytes=1 copied=1 data=A truncated=no
datagram=2 bytes=6 copied=3 data=BCD truncated=yes
datagram=3 bytes=0 copied=0 data= truncated=no

Read the program

Complete file: app/udp-boundaries/main.cpp. The two descriptors, address setup, sends, receives and final close() calls are all in this file.

#include <algorithm>
#include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <iostream>
#include <poll.h>
#include <stdexcept>
#include <string_view>
#include <sys/socket.h>
#include <system_error>
#include <unistd.h>

int main() {
    int sender = -1;
    int receiver = -1;
    int status = 0;
    try {
        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); // Ask Linux to choose a free port.
        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");
        }

        const std::array<std::string_view, 3> messages{"A", "BCDEFG", ""};
        for (const auto message : messages) {
            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");
            }
        }

        std::array<char, 3> buffer{};
        for (int i = 0; i < 3; ++i) {
            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");
            }
            // Linux MSG_TRUNC returns the original datagram length.
            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");
            }
            const auto copied =
                std::min(buffer.size(), static_cast<std::size_t>(size));
            const std::string_view data(buffer.data(), copied);
            std::cout << "datagram=" << i + 1 << " bytes=" << size
                      << " copied=" << copied << " data=" << data
                      << " truncated="
                      << (static_cast<std::size_t>(size) > buffer.size() ? "yes"
                                                                         : "no")
                      << '\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;
}

Binding port zero asks Linux for an available local port. getsockname() retrieves that actual port before the sender uses it. This avoids relying on a fixed port for the experiment.

The receive buffer has three elements. With Linux's input MSG_TRUNC flag, the return value reports the original datagram size, even when only three bytes fit. std::min() limits the displayed view to bytes actually copied; using the returned length directly here would read past the buffer.

The discarded EFG bytes do not become the next datagram. The next receive reads the explicitly sent empty message. On UDP, a successful zero-length receive can mean an empty datagram; it does not carry TCP's end-of-stream meaning.

Change the buffer

Edit std::array<char, 3> to std::array<char, 8>. The truncation check already uses buffer.size(). Rebuild and run.

The second line should now report six copied bytes, data=BCDEFG and truncated=no. The first call still receives only A, even though seven bytes remain unused in its buffer. Restore the file before running the unchanged exact-output tests.

This fixture schedules three local messages. It does not promise their delivery across an arbitrary network. Larger messages may also encounter IP fragmentation or size limits.

Check your understanding

Can another receive with a larger buffer recover the truncated tail of BCDEFG?

Compare your reasoning

No. That datagram has already been consumed and its excess bytes discarded. The application would need the sender to transmit the information again.

Continue the course

Previous: Your First UDP Exchange · Course overview · Next: Loss, Duplicates and Reordering

Reference: recvfrom and MSG_TRUNC.

Donate