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
sendis 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_framemay only tighten it.kMaxDatagramis 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=listenerreply 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_rxis this node’s resources (RX-backend exhaustion — backpressure, never an OOM);malformed_rxis 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_testdoes, so nothing here can collide with what else is running.This target needs the UDP transport. It is built only when
LIBTRACER_TRANSPORT_UDPis 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.