A stream has no boundaries, so the kind supplies them (transport plane, tcp)¶
TCP delivers bytes, not messages. Whatever write calls a sender makes, the receiver may see
them merged, split, or both — so a stream transport that handed its reader’s buffer straight up
would deliver half frames and double frames.
tcp_transport_t prefixes each frame with u32-LE length and reads it back in two steps: read
four bytes, then read exactly that many. The prefix is transport framing — it is on the
wire, it is not in the TLV, and nothing above the transport ever sees it.
What to notice¶
Why a FIXED-width prefix and not the TLV’s own length field. A variable-width header cannot be read without first buffering an unknown number of bytes, which is the problem the prefix exists to solve. Four bytes, always, then the body.
Coalesced. Two whole records in one
writearrive as two frames, split at the right byte and in order.Split. One record dribbled out in three
writes — the four-byte prefix itself torn in half — arrives as one frame, and no fourth frame is invented from the fragments. This is the case the fixed width has to survive: the reader cannot even know the length until all four bytes are in hand.The prefix is demonstrated, not asserted. The example reads a transport-sent frame off a raw socket and decodes the length itself, so the framing is visible on the wire rather than described.
The peer is a raw POSIX socket on purpose. A second
tcp_transport_twould be the realistic peer and exactly the wrong tool: it would choose the write boundaries itself and hide the thing being shown.The fourth stream hazard is deliberately elsewhere. A prefix above the effective cap tears the connection down, because a stream that has lost framing sync cannot be resynchronized — that is a lifecycle concept, and
tcp_test’s oversize-prefix case owns it.This target needs the TCP transport. It is built only when
LIBTRACER_TRANSPORT_TCPis 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 — a STREAM kind has to supply the frame boundaries the wire does not
9 * have, and `tcp` does it with a 4-byte little-endian length prefix that is
10 * TRANSPORT framing: it is on the wire, it is not in the TLV, and nothing above the
11 * transport ever sees it.
12 *
13 * TCP delivers bytes, not messages. Whatever `write` calls a sender makes, the receiver may
14 * see them merged, split, or both — so a stream transport that handed its reader's buffer
15 * straight up would deliver half frames and double frames. `tcp_transport_t` prefixes each
16 * frame with `u32-LE length` and reads it back in two steps: read four bytes, then read
17 * exactly that many. That is why the prefix is a FIXED width and not the TLV's own length
18 * field — a variable-width header cannot be read without first buffering an unknown number
19 * of bytes, which is the problem it was supposed to solve.
20 *
21 * The three stream hazards, each provoked on purpose against a raw POSIX socket so the
22 * boundaries are this example's to choose rather than the kernel's to decide:
23 *
24 * 1. **Coalesced.** Two whole records in ONE `write` arrive as TWO frames.
25 * 2. **Split.** One record dribbled out in three `write`s — the length prefix itself torn
26 * in half — arrives as ONE frame.
27 * 3. **Framed on egress too.** A frame the transport SENDS is read off the raw socket as
28 * `u32-LE length ++ bytes`, so the prefix is demonstrated on the wire and not merely
29 * asserted about.
30 *
31 * A prefix above the effective cap is the fourth case and is deliberately NOT here: it
32 * tears the connection down, which is a lifecycle concept rather than a framing one, and it
33 * is what `tcp_test`'s oversize-prefix case covers.
34 *
35 * Needs the TCP transport (`LIBTRACER_TRANSPORT_TCP`, on by default). Runs under ctest as
36 * `example_net_tcp_stream_framing`; returns non-zero on any failed check.
37 */
38
39#include <arpa/inet.h>
40#include <netinet/in.h>
41#include <poll.h>
42#include <sys/socket.h>
43#include <unistd.h>
44
45#include <chrono>
46#include <condition_variable>
47#include <cstddef>
48#include <cstdint>
49#include <cstdio>
50#include <mutex>
51#include <span>
52#include <vector>
53
54#include "libtracer/transport_tcp.hpp"
55
56namespace {
57
58using namespace std::chrono_literals;
59using tr::net::tcp_transport_t;
60
61/** @brief Report expectation @p what and record a failure on @p ok. */
62void check(bool& ok, bool cond, const char* what) {
63 std::printf(" [%s] %s\n", cond ? "ok" : "FAIL", what);
64 ok = ok && cond;
65}
66
67/** @brief A thread-safe borrowed-span sink: the recv thread pushes, `main` waits. */
68class sink_t {
69 public:
70 /** @brief The receiver callback — copies the span, which dies when it returns. */
71 void operator()(std::span<const std::byte> frame) {
72 {
73 const std::lock_guard lock(m_);
74 frames_.emplace_back(frame.begin(), frame.end());
75 }
76 cv_.notify_all();
77 }
78
79 /** @brief Wait until at least @p n frames have landed, or @p budget expires. */
80 [[nodiscard]] bool wait_for(std::size_t n, std::chrono::milliseconds budget) {
81 std::unique_lock lock(m_);
82 return cv_.wait_for(lock, budget, [&] { return frames_.size() >= n; });
83 }
84
85 /** @brief How many frames have landed so far. */
86 [[nodiscard]] std::size_t count() const {
87 const std::lock_guard lock(m_);
88 return frames_.size();
89 }
90
91 /** @brief Frame @p i, by value. */
92 [[nodiscard]] std::vector<std::byte> at(std::size_t i) const {
93 const std::lock_guard lock(m_);
94 return frames_.at(i);
95 }
96
97 private:
98 mutable std::mutex m_;
99 std::condition_variable cv_;
100 std::vector<std::vector<std::byte>> frames_;
101};
102
103/**
104 * @brief A raw POSIX TCP client — this example's own hand on the wire.
105 *
106 * A second `tcp_transport_t` would be the realistic peer, and it is exactly the wrong tool
107 * here: it would choose the write boundaries itself and hide the framing being demonstrated.
108 */
109class raw_client_t {
110 public:
111 /** @brief Connect to `127.0.0.1:@p port`; @ref ok reports whether it succeeded. */
112 explicit raw_client_t(std::uint16_t port) {
113 fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
114 sockaddr_in peer{};
115 peer.sin_family = AF_INET;
116 peer.sin_port = htons(port);
117 ::inet_pton(AF_INET, "127.0.0.1", &peer.sin_addr);
118 if (::connect(fd_, reinterpret_cast<sockaddr*>(&peer), sizeof(peer)) < 0) {
119 ::close(fd_);
120 fd_ = -1;
121 }
122 }
123 ~raw_client_t() {
124 if (fd_ >= 0) ::close(fd_);
125 }
126
127 raw_client_t(const raw_client_t&) = delete;
128 raw_client_t& operator=(const raw_client_t&) = delete;
129
130 /** @brief True iff the connect succeeded. */
131 [[nodiscard]] bool ok() const noexcept { return fd_ >= 0; }
132
133 /** @brief Push @p bytes as ONE write, resuming partials — one chosen boundary. */
134 void write(std::span<const std::byte> bytes) {
135 std::size_t off = 0;
136 while (off < bytes.size()) {
137 const ssize_t n = ::send(fd_, bytes.data() + off, bytes.size() - off, 0);
138 if (n <= 0) return;
139 off += static_cast<std::size_t>(n);
140 }
141 }
142
143 /** @brief Read up to @p want bytes within @p budget, answering whatever arrived. */
144 [[nodiscard]] std::vector<std::byte> read_within(std::size_t want,
145 std::chrono::milliseconds budget) {
146 std::vector<std::byte> got;
147 const auto deadline = std::chrono::steady_clock::now() + budget;
148 while (got.size() < want) {
149 const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(
150 deadline - std::chrono::steady_clock::now());
151 if (left.count() <= 0) break;
152 pollfd p{fd_, POLLIN, 0};
153 if (::poll(&p, 1, static_cast<int>(left.count())) <= 0) break;
154 std::byte buf[256];
155 const ssize_t n = ::recv(fd_, buf, sizeof(buf), 0);
156 if (n <= 0) break;
157 got.insert(got.end(), buf, buf + n);
158 }
159 return got;
160 }
161
162 private:
163 int fd_ = -1;
164};
165
166/** @brief @p n bytes counting up from @p seed — a stand-in for an encoded frame. */
167std::vector<std::byte> frame_of(std::size_t n, unsigned seed) {
168 std::vector<std::byte> f(n);
169 for (std::size_t i = 0; i < n; ++i) f[i] = static_cast<std::byte>(seed + i);
170 return f;
171}
172
173/** @brief One wire record: `u32-LE length ++ @p payload` — the framing, spelled by hand. */
174std::vector<std::byte> record(std::span<const std::byte> payload) {
175 const auto len = static_cast<std::uint32_t>(payload.size());
176 std::vector<std::byte> out;
177 for (unsigned shift = 0; shift < 32; shift += 8)
178 out.push_back(static_cast<std::byte>((len >> shift) & 0xFFu));
179 out.insert(out.end(), payload.begin(), payload.end());
180 return out;
181}
182
183/** @brief Decode a `u32-LE` at the front of @p bytes — the prefix, read back. */
184std::uint32_t le32(std::span<const std::byte> bytes) {
185 std::uint32_t v = 0;
186 for (unsigned i = 0; i < 4; ++i) v |= static_cast<std::uint32_t>(bytes[i]) << (8 * i);
187 return v;
188}
189
190} // namespace
191
192int main() {
193 bool ok = true;
194
195 sink_t at_listener;
196 tcp_transport_t listener(std::uint16_t{0});
197 listener.set_receiver(at_listener);
198 check(ok, listener.ok(), "the listener bound an ephemeral port");
199
200 raw_client_t client(listener.local_port());
201 check(ok, client.ok(), "the raw client connected");
202
203 // 1. COALESCED. Two complete records in one write. A reader that trusted its read
204 // boundaries would deliver this as one frame of the wrong length.
205 std::printf("two records in one write:\n");
206 const auto a = frame_of(5, 0x10);
207 const auto b = frame_of(9, 0x20);
208 std::vector<std::byte> both = record(a);
209 const auto rec_b = record(b);
210 both.insert(both.end(), rec_b.begin(), rec_b.end());
211 client.write(both);
212 check(ok, at_listener.wait_for(2, 2s), "arrived as TWO frames");
213 check(ok, at_listener.at(0) == a && at_listener.at(1) == b,
214 "…split at the right byte, in order");
215
216 // 2. SPLIT. One record in three writes, with the 4-byte prefix itself torn in half — the
217 // case a fixed-width prefix has to survive, since the reader cannot even know the frame
218 // length until all four bytes are in hand.
219 std::printf("one record in three writes, prefix torn in half:\n");
220 const auto c = frame_of(12, 0x30);
221 const auto rec_c = record(c);
222 client.write(std::span(rec_c).first(2));
223 client.write(std::span(rec_c).subspan(2, 6));
224 client.write(std::span(rec_c).subspan(8));
225 check(ok, at_listener.wait_for(3, 2s), "reassembled into ONE frame");
226 check(ok, at_listener.at(2) == c, "…byte-identical to what was framed");
227 check(ok, at_listener.count() == 3, "and no fourth frame was invented from the fragments");
228
229 // 3. EGRESS. The prefix is not an internal convention — it is on the wire, and the raw
230 // client reads it there.
231 std::printf("the prefix, read off the wire:\n");
232 const auto out = frame_of(7, 0x40);
233 listener.send(out);
234 const auto wire = client.read_within(4 + out.size(), 2s);
235 check(ok, wire.size() == 4 + out.size(), "the transport wrote exactly prefix + frame");
236 check(ok, wire.size() >= 4 && le32(wire) == out.size(),
237 "…the first four bytes are the frame length, little-endian");
238 check(ok, std::vector<std::byte>(wire.begin() + 4, wire.end()) == out,
239 "…and the rest is the frame, untouched");
240
241 check(ok, listener.malformed_rx() == 0, "no prefix on this run was refused as malformed");
242
243 std::printf("tcp: %zu frames reassembled from 4 writes, 4 bytes of framing each\n",
244 at_listener.count());
245 return ok ? 0 : 1;
246}
See also: transport module · frame codec · data format reference · the datagram family, which needs none of this · dial and listen.