The one BUS kind, and where its peer names come from (transport plane, can)¶
Every other kind in the tree is point-to-point: one link, one far side, so the child NAME the router registered for the link already addresses it. A CAN bus breaks that — one link reaches every node on the wire — and the routing plane still needs a hop segment per peer.
tr::net::bus_link_t is how a kind answers that without the graph growing per peer
(ADR-0044 §1:
no vertex is ever created for a peer).
What to notice¶
The peer list is a snapshot of traffic, not a registry.
enumerate_peerswalks a last-heard table refreshed by other nodes’ own frames and seeded by the hello advertise a node emits at join. A node silent longer thanpeer_ttlsimply stops being listed, and nothing had to notice it leave. No join protocol, no coordinator, no departure event.Names are DERIVED, not assigned.
n<node-id>comes straight out of the structured CAN ID, so it is collision-safe by construction and a rejoining node reappears under the name it had. Contrast the stream servers, which name peersp<slot>positionally — see the multi-peer listener for why that difference decides whether a resolved endpoint may be cached.A directed send on a broadcast medium. Every node’s link sees the CAN frames; the group’s advertise carries
target_node, so only the addressed peer reassembles and delivers. The example checks both halves — node 2 got the frame byte-exact, node 3 got nothing.The inbound seam speaks HANDLES, not names (#1294). The flat
transport_tsink is handed bytes and nothing else, which is complete on a point-to-point link and not on a bus;bus_link_t::set_peer_receivertags each delivery with the sender’speer_handle_t, andpeer_nameis the one bridge — a pure function of the node id, so no lock and no lookup. The example resolves it inside the delivery, which is the only place the answer is defined.The bus is in memory, and that is the point of the seam. Raw frame I/O sits behind one virtual (
can_link_t), so framing, reassembly and the peer table are exercised with no kernel CAN — and the ESP-IDF port dropstwai_link_tinto the same slotsocketcan_link_toccupies on Linux. The real-vcanpath has its own dedicated CI job.This target needs the CAN transport, and CAN implies the bus module:
transport_can.cppcarries astatic_assert(kBusLinks), because a CAN link is peer-named by construction. So this example can never be built into a target whose subject is absent — it is present or it is not compiled, and it never skips 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 — `can` is the one BUS kind: many peers share one wire, so the link
9 * exposes them itself through @ref tr::net::bus_link_t, synthesized from live
10 * traffic — no peer ever becomes a vertex, a table row the graph owns, or any other
11 * stored state (ADR-0044 §1).
12 *
13 * Every other kind in the tree is point-to-point: one link, one far end, so the child NAME
14 * the router registered for the link already addresses it. A CAN bus breaks that — one link
15 * reaches every node on the wire — and the routing plane needs a hop segment per peer
16 * anyway. The bus facet is how a kind answers that without the graph growing per peer:
17 *
18 * - `enumerate_peers` walks a LAST-HEARD table refreshed by other nodes' own traffic and
19 * seeded by the hello advertise a node emits at join. It is a snapshot, not a registry:
20 * a node silent longer than `peer_ttl` simply stops being listed, and nothing had to
21 * notice it leave. There is no join protocol and no coordinator.
22 * - Names are `n<node-id>` — DERIVED from the structured CAN ID rather than assigned, so
23 * they are collision-safe by construction and a rejoining node reappears under the name
24 * it had. (Contrast the stream servers, which name peers `p<slot>` POSITIONALLY: those
25 * names are about the slot, not the session, which is why a pointer to one must be
26 * re-resolved per use.)
27 * - `peer_link(name)` hands back a directed sending endpoint. The medium is still a
28 * broadcast one — every node sees the CAN frames — but the group's advertise carries
29 * `target_node`, so only the addressed peer reassembles and delivers it.
30 *
31 * The bus here is in-memory. That is not a shortcut but the point of the `can_link_t` seam:
32 * raw frame I/O is one virtual, so the transport — framing, reassembly, peer table, the lot
33 * — is exercised with no kernel CAN, and the ESP-IDF port swaps `twai_link_t` in at the same
34 * seam that `socketcan_link_t` occupies on Linux.
35 *
36 * Needs the CAN transport (`LIBTRACER_TRANSPORT_CAN`, on by default). It implies the bus
37 * module: `transport_can.cpp` carries a `static_assert(kBusLinks)`, because a CAN link is
38 * peer-named by construction, so this example can never be built into a target where its
39 * subject is absent. Runs under ctest as `example_net_can_bus_peers`; returns non-zero on
40 * any failed check.
41 */
42
43#include <algorithm>
44#include <chrono>
45#include <condition_variable>
46#include <cstddef>
47#include <cstdint>
48#include <cstdio>
49#include <deque>
50#include <memory>
51#include <mutex>
52#include <span>
53#include <string>
54#include <string_view>
55#include <thread>
56#include <utility>
57#include <vector>
58
59#include "libtracer/can_framing.hpp"
60#include "libtracer/peer_handle.hpp"
61#include "libtracer/transport_can.hpp"
62
63namespace {
64
65using namespace std::chrono_literals;
66using tr::net::can_frame_data_t;
67
68/** @brief Report expectation @p what and record a failure on @p ok. */
69void check(bool& ok, bool cond, const char* what) {
70 std::printf(" [%s] %s\n", cond ? "ok" : "FAIL", what);
71 ok = ok && cond;
72}
73
74class fake_link_t;
75
76/** @brief An in-memory CAN wire: whatever one link writes, every OTHER link hears. */
77class fake_bus_t {
78 public:
79 /** @brief Join @p l to the wire. */
80 void attach(fake_link_t* l);
81 /** @brief Remove @p l from the wire. */
82 void detach(fake_link_t* l);
83 /** @brief Deliver @p f to every attached link except @p from — the broadcast medium. */
84 void broadcast(fake_link_t* from, const can_frame_data_t& f);
85
86 private:
87 mutable std::mutex m_;
88 std::vector<fake_link_t*> links_;
89};
90
91/**
92 * @brief One node's raw-frame link — the whole `can_link_t` seam, in memory.
93 *
94 * Two-phase by contract (#1186): construction only opens the link, and nothing is delivered
95 * until @ref start, which `can_transport_t` calls for its owner after installing the receiver.
96 */
97class fake_link_t final : public tr::net::can_link_t {
98 public:
99 /** @brief Open a link onto @p bus. */
100 explicit fake_link_t(fake_bus_t& bus) : bus_(bus) { bus_.attach(this); }
101 ~fake_link_t() override {
102 bus_.detach(this);
103 {
104 const std::lock_guard lock(m_);
105 stop_ = true;
106 }
107 cv_.notify_all();
108 if (worker_.joinable()) worker_.join();
109 }
110
111 fake_link_t(const fake_link_t&) = delete;
112 fake_link_t& operator=(const fake_link_t&) = delete;
113
114 void write_raw(const can_frame_data_t& f) override { bus_.broadcast(this, f); }
115 void on_receive(rx_fn_t rx) override {
116 const std::lock_guard lock(m_);
117 rx_ = std::move(rx);
118 }
119 void start() override {
120 if (!worker_.joinable()) worker_ = std::thread([this] { run(); });
121 }
122
123 /** @brief Queue @p f for this link's receive thread (called by the bus). */
124 void enqueue(const can_frame_data_t& f) {
125 {
126 const std::lock_guard lock(m_);
127 q_.push_back(f);
128 }
129 cv_.notify_one();
130 }
131
132 private:
133 /** @brief The receive thread: drain the queue into the registered sink. */
134 void run() {
135 std::unique_lock lock(m_);
136 while (true) {
137 cv_.wait(lock, [this] { return stop_ || !q_.empty(); });
138 if (stop_ && q_.empty()) return;
139 const can_frame_data_t f = q_.front();
140 q_.pop_front();
141 const rx_fn_t rx = rx_;
142 lock.unlock();
143 if (rx) rx(f);
144 lock.lock();
145 }
146 }
147
148 fake_bus_t& bus_;
149 rx_fn_t rx_;
150 std::deque<can_frame_data_t> q_;
151 mutable std::mutex m_;
152 std::condition_variable cv_;
153 bool stop_ = false;
154 std::thread worker_;
155};
156
157void fake_bus_t::attach(fake_link_t* l) {
158 const std::lock_guard lock(m_);
159 links_.push_back(l);
160}
161
162void fake_bus_t::detach(fake_link_t* l) {
163 const std::lock_guard lock(m_);
164 for (auto it = links_.begin(); it != links_.end(); ++it) {
165 if (*it == l) {
166 links_.erase(it);
167 break;
168 }
169 }
170}
171
172void fake_bus_t::broadcast(fake_link_t* from, const can_frame_data_t& f) {
173 const std::lock_guard lock(m_);
174 for (auto* l : links_)
175 if (l != from) l->enqueue(f);
176}
177
178/** @brief A thread-safe frame counter for one node's inbound frames. */
179class sink_t {
180 public:
181 /** @brief The receiver callback — copies the span, which dies when it returns. */
182 void operator()(std::span<const std::byte> frame) {
183 {
184 const std::lock_guard lock(m_);
185 frames_.emplace_back(frame.begin(), frame.end());
186 }
187 cv_.notify_all();
188 }
189
190 /** @brief Wait until at least @p n frames have landed, or @p budget expires. */
191 [[nodiscard]] bool wait_for(std::size_t n, std::chrono::milliseconds budget) {
192 std::unique_lock lock(m_);
193 return cv_.wait_for(lock, budget, [&] { return frames_.size() >= n; });
194 }
195
196 /** @brief How many frames have landed so far. */
197 [[nodiscard]] std::size_t count() const {
198 const std::lock_guard lock(m_);
199 return frames_.size();
200 }
201
202 /** @brief Frame @p i, by value. */
203 [[nodiscard]] std::vector<std::byte> at(std::size_t i) const {
204 const std::lock_guard lock(m_);
205 return frames_.at(i);
206 }
207
208 private:
209 mutable std::mutex m_;
210 std::condition_variable cv_;
211 std::vector<std::vector<std::byte>> frames_;
212};
213
214/**
215 * @brief A PEER-NAMED sink: every delivery arrives tagged with the sending peer's handle.
216 *
217 * This is the bus's own inbound seam (`bus_link_t::set_peer_receiver`) rather than the flat
218 * `transport_t` one, and the difference is the whole reason it exists. The flat sink is
219 * handed bytes and nothing else — on a point-to-point link that is complete, because the
220 * link's registered child NAME already says who the far side is. On a bus it is not, and the
221 * handle is what closes the gap. It is a HANDLE and not a name because a name is a string
222 * the consumer would have to re-derive an identity from on every frame; @ref peer_name is
223 * the one bridge, called here inside the delivery where the answer is defined.
224 */
225class named_sink_t {
226 public:
227 /** @brief Bind the link whose deliveries this sink will name; call before frames flow. */
228 void bind(tr::net::can_transport_t& link) { link_ = &link; }
229
230 /** @brief The peer-named receiver callback — record who sent @p frame, then @p frame. */
231 void operator()(tr::net::peer_handle_t peer, std::span<const std::byte> frame) {
232 char scratch[tr::net::kPeerNameChars];
233 std::string sender;
234 if (link_ != nullptr) sender = link_->peer_name(peer, scratch);
235 {
236 const std::lock_guard lock(m_);
237 senders_.push_back(std::move(sender));
238 frames_.emplace_back(frame.begin(), frame.end());
239 }
240 cv_.notify_all();
241 }
242
243 /** @brief Wait until at least @p n frames have landed, or @p budget expires. */
244 [[nodiscard]] bool wait_for(std::size_t n, std::chrono::milliseconds budget) {
245 std::unique_lock lock(m_);
246 return cv_.wait_for(lock, budget, [&] { return frames_.size() >= n; });
247 }
248
249 /** @brief How many frames have landed so far. */
250 [[nodiscard]] std::size_t count() const {
251 const std::lock_guard lock(m_);
252 return frames_.size();
253 }
254
255 /** @brief Frame @p i, by value. */
256 [[nodiscard]] std::vector<std::byte> at(std::size_t i) const {
257 const std::lock_guard lock(m_);
258 return frames_.at(i);
259 }
260
261 /** @brief The peer name frame @p i arrived from. */
262 [[nodiscard]] std::string sender(std::size_t i) const {
263 const std::lock_guard lock(m_);
264 return senders_.at(i);
265 }
266
267 private:
268 tr::net::can_transport_t* link_ = nullptr;
269 mutable std::mutex m_;
270 std::condition_variable cv_;
271 std::vector<std::vector<std::byte>> frames_;
272 std::vector<std::string> senders_;
273};
274
275/** @brief A `can_transport_t` node on @p bus with id @p node, advertising @p path. */
276std::unique_ptr<tr::net::can_transport_t> make_node(fake_bus_t& bus, std::uint16_t node,
277 std::string path) {
278 tr::net::transport_can_config_t cfg;
279 cfg.node = node;
280 cfg.mode = tr::net::can::can_frame_mode_t::CLASSIC;
281 cfg.path = std::move(path);
282 return std::make_unique<tr::net::can_transport_t>(
283 tr::mem::make_poly<fake_link_t>(tr::mem::net_source(), bus), cfg);
284}
285
286/** @brief The peer names @p link currently hears, in enumeration order. */
287std::vector<std::string> audible(tr::net::bus_link_t& link) {
288 std::vector<std::string> names;
289 link.enumerate_peers([&](std::string_view n) { names.emplace_back(n); });
290 return names;
291}
292
293/**
294 * @brief Poll until @p link hears at least @p n peers, or @p budget expires.
295 *
296 * A poll and not a wait, deliberately: peer presence is a LIVENESS observation derived from
297 * traffic, so there is no edge to wait on — a peer becomes audible because a frame happened
298 * to arrive, and stops being audible because nothing did. The bounded loop is the honest
299 * shape for that; it is not a stand-in for a rendezvous the API offers and this skipped.
300 */
301bool wait_for_peers(tr::net::bus_link_t& link, std::size_t n, std::chrono::milliseconds budget) {
302 const auto deadline = std::chrono::steady_clock::now() + budget;
303 while (std::chrono::steady_clock::now() < deadline) {
304 if (audible(link).size() >= n) return true;
305 std::this_thread::sleep_for(2ms);
306 }
307 return audible(link).size() >= n;
308}
309
310/** @brief @p n bytes counting up from @p seed — a stand-in for an encoded frame. */
311std::vector<std::byte> frame_of(std::size_t n, unsigned seed) {
312 std::vector<std::byte> f(n);
313 for (std::size_t i = 0; i < n; ++i) f[i] = static_cast<std::byte>(seed + i);
314 return f;
315}
316
317} // namespace
318
319int main() {
320 bool ok = true;
321 fake_bus_t bus;
322
323 // Three nodes on ONE wire. Node 1 is the observer; 2 and 3 join after it, so their hello
324 // advertises are what it learns them from.
325 sink_t at1, at3;
326 named_sink_t at2;
327 auto n1 = make_node(bus, 1, "/n1");
328 n1->set_receiver(at1);
329 auto n2 = make_node(bus, 2, "/n2");
330 at2.bind(*n2);
331 n2->bus()->set_peer_receiver(at2);
332 auto n3 = make_node(bus, 3, "/n3");
333 n3->set_receiver(at3);
334
335 check(ok, n1->bus() != nullptr,
336 "a CAN link exposes the bus facet; a point-to-point one does not");
337
338 // The peers are synthesized from traffic. Nothing was registered, nothing was created.
339 std::printf("who is audible on the wire:\n");
340 tr::net::bus_link_t& facet = *n1->bus();
341 check(ok, wait_for_peers(facet, 2, 2s), "node 1 heard both of the nodes that joined after it");
342 const auto names = audible(facet);
343 const bool has_two = std::find(names.begin(), names.end(), "n2") != names.end();
344 const bool has_three = std::find(names.begin(), names.end(), "n3") != names.end();
345 check(ok, has_two && has_three, "…named n2 and n3, derived from their CAN node ids");
346 check(ok, std::find(names.begin(), names.end(), "n1") == names.end(),
347 "…and never itself: the table is who I HEARD, not who is here");
348
349 // A name that no node on this bus spells resolves to nothing — the enumeration is the
350 // authority, and there is no fallback that would invent a route.
351 check(ok, facet.peer_link("n9") == nullptr, "an unheard peer name resolves to no endpoint");
352 check(ok, facet.peer_link("bogus") == nullptr, "a non-canonical name resolves to no endpoint");
353
354 // A DIRECTED send on a broadcast medium: every node's link sees the CAN frames, but the
355 // advertise names node 2, so only node 2 reassembles a frame out of them.
356 std::printf("a directed send, on a medium that broadcasts:\n");
357 tr::net::transport_t* to_n2 = facet.peer_link("n2");
358 check(ok, to_n2 != nullptr, "peer_link('n2') resolved a directed endpoint");
359 const auto payload = frame_of(20, 0x10);
360 if (to_n2 != nullptr) to_n2->send(payload);
361 check(ok, at2.wait_for(1, 2s), "node 2 delivered the frame");
362 check(ok, at2.count() == 1 && at2.at(0) == payload,
363 "…byte-exact, reassembled from 8-byte data fields");
364 check(ok, at3.count() == 0, "node 3 heard the frames and delivered NOTHING — not addressed");
365
366 // The inbound side of the same identity: node 2 named its sender from the handle the
367 // delivery carried — a pure function of the CAN node id, so no lookup, no lock, and no
368 // per-request state on either side of the exchange.
369 check(ok, at2.count() == 1 && at2.sender(0) == "n1",
370 "node 2 resolved the sender's name from the frame's own CAN id");
371
372 std::printf("can: %zu peers audible to n1, 1 directed frame, %zu nodes that ignored it\n",
373 names.size(), std::size_t{1});
374 return ok ? 0 : 1;
375}
See also: CAN module · CAN transport reference · transport module · the positional naming regime.