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/peer_handle.hpp"
60#include "libtracer/transport_can.hpp"
61#include "libtracer/view_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 `transport_can` 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::transport_can& 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::transport_can* 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 `transport_can` node on @p bus with id @p node, advertising @p path. */
276std::unique_ptr<tr::net::transport_can> 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::view::can_frame_mode_t::CLASSIC;
281 cfg.path = std::move(path);
282 return std::make_unique<tr::net::transport_can>(std::make_unique<fake_link_t>(bus), cfg);
283}
284
285/** @brief The peer names @p link currently hears, in enumeration order. */
286std::vector<std::string> audible(tr::net::bus_link_t& link) {
287 std::vector<std::string> names;
288 link.enumerate_peers([&](std::string_view n) { names.emplace_back(n); });
289 return names;
290}
291
292/**
293 * @brief Poll until @p link hears at least @p n peers, or @p budget expires.
294 *
295 * A poll and not a wait, deliberately: peer presence is a LIVENESS observation derived from
296 * traffic, so there is no edge to wait on — a peer becomes audible because a frame happened
297 * to arrive, and stops being audible because nothing did. The bounded loop is the honest
298 * shape for that; it is not a stand-in for a rendezvous the API offers and this skipped.
299 */
300bool wait_for_peers(tr::net::bus_link_t& link, std::size_t n, std::chrono::milliseconds budget) {
301 const auto deadline = std::chrono::steady_clock::now() + budget;
302 while (std::chrono::steady_clock::now() < deadline) {
303 if (audible(link).size() >= n) return true;
304 std::this_thread::sleep_for(2ms);
305 }
306 return audible(link).size() >= n;
307}
308
309/** @brief @p n bytes counting up from @p seed — a stand-in for an encoded frame. */
310std::vector<std::byte> frame_of(std::size_t n, unsigned seed) {
311 std::vector<std::byte> f(n);
312 for (std::size_t i = 0; i < n; ++i) f[i] = static_cast<std::byte>(seed + i);
313 return f;
314}
315
316} // namespace
317
318int main() {
319 bool ok = true;
320 fake_bus_t bus;
321
322 // Three nodes on ONE wire. Node 1 is the observer; 2 and 3 join after it, so their hello
323 // advertises are what it learns them from.
324 sink_t at1, at3;
325 named_sink_t at2;
326 auto n1 = make_node(bus, 1, "/n1");
327 n1->set_receiver(at1);
328 auto n2 = make_node(bus, 2, "/n2");
329 at2.bind(*n2);
330 n2->bus()->set_peer_receiver(at2);
331 auto n3 = make_node(bus, 3, "/n3");
332 n3->set_receiver(at3);
333
334 check(ok, n1->bus() != nullptr,
335 "a CAN link exposes the bus facet; a point-to-point one does not");
336
337 // The peers are synthesized from traffic. Nothing was registered, nothing was created.
338 std::printf("who is audible on the wire:\n");
339 tr::net::bus_link_t& facet = *n1->bus();
340 check(ok, wait_for_peers(facet, 2, 2s), "node 1 heard both of the nodes that joined after it");
341 const auto names = audible(facet);
342 const bool has_two = std::find(names.begin(), names.end(), "n2") != names.end();
343 const bool has_three = std::find(names.begin(), names.end(), "n3") != names.end();
344 check(ok, has_two && has_three, "…named n2 and n3, derived from their CAN node ids");
345 check(ok, std::find(names.begin(), names.end(), "n1") == names.end(),
346 "…and never itself: the table is who I HEARD, not who is here");
347
348 // A name that no node on this bus spells resolves to nothing — the enumeration is the
349 // authority, and there is no fallback that would invent a route.
350 check(ok, facet.peer_link("n9") == nullptr, "an unheard peer name resolves to no endpoint");
351 check(ok, facet.peer_link("bogus") == nullptr, "a non-canonical name resolves to no endpoint");
352
353 // A DIRECTED send on a broadcast medium: every node's link sees the CAN frames, but the
354 // advertise names node 2, so only node 2 reassembles a frame out of them.
355 std::printf("a directed send, on a medium that broadcasts:\n");
356 tr::net::transport_t* to_n2 = facet.peer_link("n2");
357 check(ok, to_n2 != nullptr, "peer_link('n2') resolved a directed endpoint");
358 const auto payload = frame_of(20, 0x10);
359 if (to_n2 != nullptr) to_n2->send(payload);
360 check(ok, at2.wait_for(1, 2s), "node 2 delivered the frame");
361 check(ok, at2.count() == 1 && at2.at(0) == payload,
362 "…byte-exact, reassembled from 8-byte data fields");
363 check(ok, at3.count() == 0, "node 3 heard the frames and delivered NOTHING — not addressed");
364
365 // The inbound side of the same identity: node 2 named its sender from the handle the
366 // delivery carried — a pure function of the CAN node id, so no lookup, no lock, and no
367 // per-request state on either side of the exchange.
368 check(ok, at2.count() == 1 && at2.sender(0) == "n1",
369 "node 2 resolved the sender's name from the frame's own CAN id");
370
371 std::printf("can: %zu peers audible to n1, 1 directed frame, %zu nodes that ignored it\n",
372 names.size(), std::size_t{1});
373 return ok ? 0 : 1;
374}
See also: CAN module · CAN transport reference · transport module · the positional naming regime.