A datagram already has boundaries (transport plane, udp)

Every stream kind in the tree — tcp, ws, quic — has to invent message boundaries, because a stream has none. UDP already has them, so udp_transport_t adds nothing: a send is one sendto, an inbound datagram is one frame, and there is no reassembler, no length prefix and no partial-frame state anywhere in the kind.

That single difference is what the whole page is about, and it has two consequences worth seeing before wiring a datagram link.

What to notice

  • One datagram is one frame, with zero bytes of framing. What the sender handed to send is what the receiver’s callback sees, and its length came from the datagram rather than from anything in the bytes. Two sends are two frames — never one coalesced read the receiver has to split apart, which is exactly the work the TCP page shows.

  • The bound is hard, and max_frame may only tighten it. kMaxDatagram is what a datagram can physically be, so a configured cap above it is inert rather than loosening. A frame larger than a datagram is not a UDP frame; that is what a streaming kind is for.

  • The listener has no peer until one talks to it. Constructed with no peer address, the transport LEARNS its peer from the source address of the first inbound datagram. That is what lets a config-created role=listener reply to a dialer whose ephemeral source port could not have been known in advance.

  • A send before the peer is known is a no-op — not an error, and not a queued frame. There is no address to send to and UDP has nowhere to hold it. The example asserts it rather than leaving it to be discovered.

  • The two counters mean different things. dropped_rx is this node’s resources (RX-backend exhaustion — backpressure, never an OOM); malformed_rx is the peer’s fault (a datagram over the cap). UDP is connectionless, so neither tears anything down: the next datagram is served normally.

  • Both sockets bind port 0. Real loopback sockets on kernel-chosen ports, the way udp_test does, so nothing here can collide with what else is running.

  • This target needs the UDP transport. It is built only when LIBTRACER_TRANSPORT_UDP is on (the default). Nothing in it is conditional at run time.

Source

  1/*
  2 * SPDX-License-Identifier: Apache-2.0
  3 * SPDX-FileCopyrightText: Copyright 2026 avatarsd LLC
  4 */
  5
  6/**
  7 * @file
  8 * @brief ONE CONCEPT — on a DATAGRAM kind the frame boundary is the wire's own, so `udp`
  9 *        carries no framing layer at all: one datagram in, one whole frame out, and the
 10 *        MTU is therefore the frame size limit rather than a tuning knob.
 11 *
 12 * Every stream kind in the tree (`tcp`, `ws`, `quic`) has to invent message boundaries,
 13 * because a stream has none. UDP already has them, so `udp_transport_t` adds nothing: a
 14 * `send` is one `sendto`, an inbound datagram is one frame, and there is no reassembler,
 15 * no length prefix and no partial-frame state anywhere in the kind. That is the whole
 16 * difference between the two families, and it has two consequences worth seeing:
 17 *
 18 *  - **The bound is hard, not configurable upward.** `kMaxDatagram` is what a datagram can
 19 *    physically be, so the `:settings max_frame` key can only TIGHTEN it. A frame larger
 20 *    than a datagram is not a UDP frame; that is what streaming kinds are for.
 21 *  - **The listener has no peer until one talks to it.** UDP is connectionless, so a
 22 *    listener-mode transport — constructed with no peer address — LEARNS its peer from the
 23 *    source address of the first inbound datagram. Until then `send` is a no-op, which is
 24 *    exactly what lets a config-created listener reply to a dialer whose ephemeral source
 25 *    port could not have been known in advance.
 26 *
 27 * The example runs two real UDP sockets on the loopback interface, the way `udp_test` does:
 28 * both bind port 0, so the kernel picks the ports and nothing here can collide with
 29 * whatever else is running on the machine.
 30 *
 31 * Needs the UDP transport (`LIBTRACER_TRANSPORT_UDP`, on by default). Runs under ctest as
 32 * `example_net_udp_datagram`; returns non-zero on any failed check.
 33 */
 34
 35#include <chrono>
 36#include <condition_variable>
 37#include <cstddef>
 38#include <cstdint>
 39#include <cstdio>
 40#include <mutex>
 41#include <span>
 42#include <vector>
 43
 44#include "libtracer/transport_udp.hpp"
 45
 46namespace {
 47
 48using namespace std::chrono_literals;
 49using tr::net::udp_transport_t;
 50
 51/** @brief Report expectation @p what and record a failure on @p ok. */
 52void check(bool& ok, bool cond, const char* what) {
 53    std::printf("  [%s] %s\n", cond ? "ok" : "FAIL", what);
 54    ok = ok && cond;
 55}
 56
 57/** @brief A thread-safe borrowed-span sink: the recv thread pushes, `main` waits. */
 58class sink_t {
 59   public:
 60    /** @brief The receiver callback — copies the span, which dies when it returns. */
 61    void operator()(std::span<const std::byte> frame) {
 62        {
 63            const std::lock_guard lock(m_);
 64            frames_.emplace_back(frame.begin(), frame.end());
 65        }
 66        cv_.notify_all();
 67    }
 68
 69    /** @brief Wait until at least @p n frames have landed, or @p budget expires. */
 70    [[nodiscard]] bool wait_for(std::size_t n, std::chrono::milliseconds budget) {
 71        std::unique_lock lock(m_);
 72        return cv_.wait_for(lock, budget, [&] { return frames_.size() >= n; });
 73    }
 74
 75    /** @brief How many frames have landed so far. */
 76    [[nodiscard]] std::size_t count() const {
 77        const std::lock_guard lock(m_);
 78        return frames_.size();
 79    }
 80
 81    /** @brief Frame @p i, by value. */
 82    [[nodiscard]] std::vector<std::byte> at(std::size_t i) const {
 83        const std::lock_guard lock(m_);
 84        return frames_.at(i);
 85    }
 86
 87   private:
 88    mutable std::mutex m_;
 89    std::condition_variable cv_;
 90    std::vector<std::vector<std::byte>> frames_;
 91};
 92
 93/** @brief @p n bytes counting up from @p seed — a stand-in for an encoded frame. */
 94std::vector<std::byte> frame_of(std::size_t n, unsigned seed) {
 95    std::vector<std::byte> f(n);
 96    for (std::size_t i = 0; i < n; ++i) f[i] = static_cast<std::byte>(seed + i);
 97    return f;
 98}
 99
100}  // namespace
101
102int main() {
103    bool ok = true;
104
105    // The listener half: bind an ephemeral port, name NO peer. This is the shape a
106    // `role=listener` config creates, and the reason `send` has to tolerate having nobody to
107    // send to yet.
108    sink_t at_listener;
109    udp_transport_t listener(/*bind_port=*/0, /*peer_host=*/"", /*peer_port=*/0);
110    listener.set_receiver(at_listener);
111    check(ok, listener.ok(), "the listener socket bound");
112    const std::uint16_t port = listener.local_port();
113    check(ok, port != 0, "local_port() resolved the ephemeral 0");
114
115    // A send before any peer is known is a NO-OP, not an error and not a queued frame: there
116    // is no address to send to, and UDP has nowhere to hold it.
117    listener.send(frame_of(4, 0x01));
118    check(ok, at_listener.count() == 0, "a send with no peer learned yet went nowhere");
119
120    // The dialer half: also an ephemeral local port, but with the peer named up front.
121    sink_t at_dialer;
122    udp_transport_t dialer(/*bind_port=*/0, "127.0.0.1", port);
123    dialer.set_receiver(at_dialer);
124    check(ok, dialer.ok(), "the dialer socket bound");
125
126    // One datagram, one frame. No prefix is written and none is stripped — what the sender
127    // handed to `send` is what the receiver's callback sees, and its LENGTH came from the
128    // datagram rather than from anything in the bytes.
129    std::printf("one datagram is one frame:\n");
130    const auto f1 = frame_of(9, 0x10);
131    dialer.send(f1);
132    check(ok, at_listener.wait_for(1, 2s), "the datagram arrived as one whole frame");
133    check(ok, at_listener.at(0) == f1, "byte-identical, with no framing bytes added");
134
135    // Two sends are two datagrams and therefore two frames — never one coalesced read the
136    // receiver has to split. This is the property a stream kind has to reconstruct.
137    const auto f2 = frame_of(3, 0x20);
138    const auto f3 = frame_of(11, 0x30);
139    dialer.send(f2);
140    dialer.send(f3);
141    check(ok, at_listener.wait_for(3, 2s), "two more sends arrived as exactly two more frames");
142    check(ok, at_listener.at(1) == f2 && at_listener.at(2) == f3,
143          "…each with its own boundary, in order");
144
145    // The listener has now heard from the dialer, so it knows where to reply — learned from
146    // the datagram's source address, with nothing stored per request.
147    std::printf("the peer is learned from ingress:\n");
148    const auto reply = frame_of(7, 0x40);
149    listener.send(reply);
150    check(ok, at_dialer.wait_for(1, 2s), "the listener could reply once it had heard a peer");
151    check(ok, at_dialer.at(0) == reply, "…and the reply is byte-identical too");
152
153    // The bound is the datagram's, and `max_frame` may only tighten it.
154    check(ok, listener.effective_max_frame() <= udp_transport_t::kMaxDatagram,
155          "the receive cap never exceeds what a datagram can carry");
156    check(ok, listener.malformed_rx() == 0 && listener.dropped_rx() == 0,
157          "nothing was refused and nothing was shed on this run");
158
159    std::printf("udp: %zu frames at the listener, %zu at the dialer, 0 bytes of framing\n",
160                at_listener.count(), at_dialer.count());
161    return ok ? 0 : 1;
162}

See also: transport module · connection config · module catalog reference · the stream family’s answer.