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_peers walks 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 than peer_ttl simply 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 peers p<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_t sink 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_receiver tags each delivery with the sender’s peer_handle_t, and peer_name is 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 drops twai_link_t into the same slot socketcan_link_t occupies on Linux. The real-vcan path has its own dedicated CI job.

  • This target needs the CAN transport, and CAN implies the bus module: transport_can.cpp carries a static_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.