Three nodes, and a forwarder that stores nothing (L4 routing)¶
A chain is the one-hop rule applied again. A holds a child called b, B holds a child called
c, C holds the vertex; a client writes /b/c/sensor/temp. There is no route discovery, no
flooding, no forwarding information base and no per-flow entry anywhere — each node applies the
same test, and the route consumes itself as it travels
(ADR-0040).
The check that matters is the negative one. After the write has crossed B, B’s routing plane holds exactly what it held before: zero label bindings, zero link shells, the same receiver-context count. That bounds a forwarder’s memory by its topology (how many links it has) rather than by its traffic (how many flows cross it) — the reason a 16 KB node can be a forwarder at all.
What to notice¶
The “before” values are read, not assumed. The example snapshots B’s counters and compares against the snapshot. Asserting
== 0against a hard-coded zero would keep passing if the baseline ever moved.Each hop’s wire bytes are asserted.
dst=/c/sensor/temp, src=/clion the A→B wire,dst=/sensor/temp, src=/a/clion the B→C wire. The return route is visibly under construction, one hop at a time.Hops are driven explicitly. The frame a node emits is handed to the next node’s
on_frame. Aloopback_channel_tor a socket does the same thing with threads in between — see two nodes over a wire — but driving it by hand keeps the example synchronous and puts the intermediate bytes where they can be asserted on.The names are private.
bmeans something only to A andcmeans something only to B./b/c/sensor/tempis the composition, spelled by whoever holds both mounts.Statelessness is a choice with a price, and the price is on the wire. Every frame re-carries its route. The label plane is what buys that back for flows that repeat — deliberately, per flow, and only when someone asks.
This target needs the FWD net plane. It is built only when
LIBTRACER_NET_PLANEis 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 chain is just the one-hop rule applied again, and the MIDDLE hop
9 * stores nothing at all.
10 *
11 * Three nodes: A holds a child called `b`, B holds a child called `c`, C holds the vertex.
12 * A client writes `/b/c/sensor/temp`. There is no route discovery, no flooding, no forwarding
13 * information base and no per-flow entry anywhere: each node applies the same test the
14 * one-hop examples showed, and the route consumes itself as it travels
15 * (RFC-0004 §B, ADR-0040).
16 *
17 * The check that matters is the negative one. After the write has crossed B, B's routing plane
18 * holds exactly what it held before: zero label bindings, zero link shells, and the same
19 * receiver-context count. That is the slice-3 stateless-forwarder property, and it is what
20 * bounds a forwarder's memory by its TOPOLOGY (how many links it has) rather than by its
21 * TRAFFIC (how many flows cross it) — the reason a 16 KB node can be a forwarder at all.
22 *
23 * Each hop is driven explicitly: the frame a node emits is handed to the next node's
24 * `on_frame`. A `loopback_channel_t` or a socket would do the same thing with threads in
25 * between (`two_node_fwd` shows that shape); doing it by hand keeps the example synchronous
26 * and puts the intermediate bytes where they can be asserted on.
27 *
28 * Runs under ctest as `example_route_multi_hop`; returns non-zero on any failed check.
29 */
30
31#include <cstddef>
32#include <cstdint>
33#include <cstdio>
34#include <initializer_list>
35#include <span>
36#include <string_view>
37#include <vector>
38
39#include "libtracer/fwd_router.hpp"
40#include "libtracer/tlv_emit.hpp"
41#include "libtracer/tracer.hpp"
42
43namespace {
44
45using tr::graph::fwd_op_t;
46using tr::graph::graph_t;
47using tr::graph::path_t;
48using tr::graph::role_t;
49using tr::wire::opt_t;
50using tr::wire::type_t;
51
52/** @brief Report expectation @p what and record a failure on @p ok. */
53void check(bool& ok, bool cond, const char* what) {
54 std::printf(" [%s] %s\n", cond ? "ok" : "FAIL", what);
55 ok = ok && cond;
56}
57
58/** @brief A `transport_t` that keeps every frame handed to it — the "wire", made inspectable. */
59struct recording_link_t : tr::net::transport_t {
60 std::vector<std::vector<std::byte>> sent; /**< @brief Frames emitted on this link, in order. */
61 void send(std::span<const std::byte> frame) override {
62 sent.emplace_back(frame.begin(), frame.end());
63 }
64 void send(std::span<const std::span<const std::byte>> iov) override {
65 std::vector<std::byte> flat;
66 for (const auto part : iov) flat.insert(flat.end(), part.begin(), part.end());
67 sent.push_back(std::move(flat));
68 }
69};
70
71/** @brief A `PATH` TLV over @p segs — RFC-0018 packed segment records, `opt.PL` clear. */
72std::vector<std::byte> path_tlv(std::initializer_list<std::string_view> segs) {
73 std::vector<std::byte> body;
74 for (std::string_view s : segs) (void)tr::wire::emit_path_segment(body, s);
75 std::vector<std::byte> out;
76 tr::wire::emit_tlv(out, type_t::PATH, opt_t{}, body);
77 return out;
78}
79
80/** @brief A one-byte `VALUE` TLV carrying @p v. */
81std::vector<std::byte> value_tlv(std::uint8_t v) {
82 const std::byte b{v};
83 std::vector<std::byte> out;
84 tr::wire::emit_tlv(out, type_t::VALUE, opt_t{}, std::span<const std::byte>(&b, 1));
85 return out;
86}
87
88/** @brief `FWD[.pl]{ VALUE op, PATH dst, PATH src, VALUE payload }` (RFC-0004 §B child order). */
89std::vector<std::byte> fwd_write(std::initializer_list<std::string_view> dst,
90 std::initializer_list<std::string_view> src) {
91 std::vector<std::byte> body = value_tlv(static_cast<std::uint8_t>(fwd_op_t::WRITE));
92 for (const auto& part : {path_tlv(dst), path_tlv(src), value_tlv(0x2A)}) {
93 body.insert(body.end(), part.begin(), part.end());
94 }
95 std::vector<std::byte> out;
96 tr::wire::emit_tlv(out, type_t::FWD, opt_t{.pl = true}, body);
97 return out;
98}
99
100} // namespace
101
102int main() {
103 bool ok = true;
104 graph_t graph_a, graph_b, graph_c;
105 tr::net::fwd_router_t router_a(graph_a), router_b(graph_b), router_c(graph_c);
106 recording_link_t a_to_b, b_to_c;
107
108 // A knows B as "b"; B knows C as "c". Neither name means anything to any other node —
109 // the client's address /b/c/... is the composition of the two, spelled by whoever holds
110 // both mounts, which is what makes the route explicit and loop-free by construction.
111 if (!router_a.add_child("b", a_to_b) || !router_b.add_child("c", b_to_c)) {
112 std::fprintf(stderr, "route_multi_hop: add_child failed — nothing registered\n");
113 return 1;
114 }
115 (void)graph_c.register_vertex(path_t("/sensor/temp"), role_t::STORED_VALUE);
116
117 // Whatever B held before the flow — nothing, but read it rather than assume it.
118 const std::size_t b_ingress_before = router_b.handles().ingress_count();
119 const std::size_t b_links_before = router_b.handles().link_count();
120 const std::size_t b_ctx_before = router_b.receiver_ctx_count();
121
122 // Hop 1: A strips "b", grows src by "cli".
123 router_a.on_frame("cli", fwd_write({"b", "c", "sensor", "temp"}, {}));
124 check(ok, a_to_b.sent.size() == 1, "A forwarded toward B");
125 if (a_to_b.sent.size() != 1) return 1;
126 check(ok, a_to_b.sent[0] == fwd_write({"c", "sensor", "temp"}, {"cli"}),
127 "the frame on the A→B wire: dst=/c/sensor/temp, src=/cli");
128
129 // Hop 2: B applies the SAME rule to the SAME frame — nothing about it is hop-aware.
130 router_b.on_frame("a", a_to_b.sent[0]);
131 check(ok, b_to_c.sent.size() == 1, "B forwarded toward C");
132 if (b_to_c.sent.size() != 1) return 1;
133 check(ok, b_to_c.sent[0] == fwd_write({"sensor", "temp"}, {"a", "cli"}),
134 "the frame on the B→C wire: dst=/sensor/temp, src=/a/cli — the return route, growing");
135
136 // Hop 3: at C the leading route segment names no child, so C is the terminus and writes.
137 router_c.on_frame("b", b_to_c.sent[0]);
138 check(ok, graph_c.read(path_t("/sensor/temp")).has_value(),
139 "C was the terminus and the write landed three hops from the client");
140
141 // The point of the example: B is exactly as it was.
142 check(ok,
143 router_b.handles().ingress_count() == b_ingress_before &&
144 router_b.handles().egress_count() == 0,
145 "the middle hop bound NO label state for the flow it just carried");
146 check(ok, router_b.handles().link_count() == b_links_before,
147 "and created no per-link shell — its memory is a function of topology, not traffic");
148 check(ok, router_b.receiver_ctx_count() == b_ctx_before,
149 "and its receiver-context chain did not grow");
150
151 std::printf("3 hops, 1 rule, %zu per-flow binding(s) on the forwarder\n",
152 router_b.handles().ingress_count() + router_b.handles().egress_count());
153 return ok ? 0 : 1;
154}
See also: fwd-router module · communication flows · composition over the network · the source route · the reply home.