Terminus or forward — one test decides (L4 routing)¶
A node does not classify traffic into “local” and “remote”, and a FWD carries no flag saying
which it is. Every inbound frame is put to the same question: is the leading dst route
segment one of my registered children? Yes ⇒ forward it. No ⇒ I am the terminus — resolve
the whole remaining dst as a local address and apply the op
(RFC-0004
§D,
ADR-0035).
The consequence worth internalising: the same bytes are a forward at one node and a terminus
at the next, decided entirely by each node’s own child table. /sensor/temp is a route while a
child is called sensor, and an address the moment that child is gone. Addressing and routing
share one namespace on purpose (CONTEXT.md §Path-as-route).
What to notice¶
The example wires the ambiguity deliberately. One node holds a child named
band a local vertex at/sensor/temp, and both arms are exercised against it — so the forward arm proves the local vertex was not touched, and the terminus arm proves it can be.The terminus failure is ADDRESSED, not dropped. A
dstthat resolves to no local vertex answersFWD{REPLY}withkind=ERROR, source-routed home along thesrcthe request accumulated. A forwarder that silently swallowed unroutable frames would turn every addressing typo into a timeout instead of a status (RFC-0002’s model, carried on the FWD plane).The reply goes out on the link the request arrived on. That is the per-hop retrace, not a lookup — the terminus does not have to know where the origin is, only which link spoke to it.
Nothing about the frame is hop-aware. No TTL, no hop count, no visited set.
dststrictly shrinks per hop, which is what makes both planes loop-free by construction (CONTEXT.md§Loop freedom).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 — one test decides a hop's whole behaviour: does the leading `dst`
9 * route segment name a CHILD?
10 *
11 * A node does not classify frames into "local" and "remote" traffic, and a `dst` carries no
12 * flag saying which it is. Every inbound `FWD` is put to the same question — is the leading
13 * `dst` route segment one of my registered children? Yes ⇒ forward it (dst-shrink / src-grow, see
14 * route_dst_is_source_route). No ⇒ **I am the terminus**: resolve the whole remaining `dst` as
15 * a local address and apply the op (RFC-0004 §D, ADR-0035).
16 *
17 * The consequence worth internalising: the SAME bytes are a forward at one node and a terminus
18 * at the next, decided entirely by each node's own child table. `/sensor/temp` is a route while
19 * a child is called `sensor`, and an address the moment that child is gone. Addressing and
20 * routing share one namespace on purpose (`CONTEXT.md` §Path-as-route).
21 *
22 * The terminus arm also has a failure mode that is NOT a drop: a `dst` that resolves to no
23 * local vertex answers `FWD{REPLY}` with `kind=ERROR`, source-routed home along the `src` the
24 * request accumulated. A forwarder that silently swallowed unroutable frames would make every
25 * addressing typo a timeout instead of an error.
26 *
27 * Runs under ctest as `example_route_terminus_or_forward`; returns non-zero on any failed check.
28 */
29
30#include <cstddef>
31#include <cstdint>
32#include <cstdio>
33#include <initializer_list>
34#include <iterator>
35#include <optional>
36#include <span>
37#include <string_view>
38#include <vector>
39
40#include "libtracer/fwd_router.hpp"
41#include "libtracer/tlv_emit.hpp"
42#include "libtracer/tracer.hpp"
43
44namespace {
45
46using tr::graph::fwd_op_t;
47using tr::graph::graph_t;
48using tr::graph::path_t;
49using tr::graph::reply_kind_t;
50using tr::graph::role_t;
51using tr::wire::opt_t;
52using tr::wire::type_t;
53
54/** @brief Report expectation @p what and record a failure on @p ok. */
55void check(bool& ok, bool cond, const char* what) {
56 std::printf(" [%s] %s\n", cond ? "ok" : "FAIL", what);
57 ok = ok && cond;
58}
59
60/** @brief A `transport_t` that keeps every frame handed to it — the "wire", made inspectable. */
61struct recording_link_t : tr::net::transport_t {
62 std::vector<std::vector<std::byte>> sent; /**< @brief Frames emitted on this link, in order. */
63 void send(std::span<const std::byte> frame) override {
64 sent.emplace_back(frame.begin(), frame.end());
65 }
66 void send(std::span<const std::span<const std::byte>> iov) override {
67 std::vector<std::byte> flat;
68 for (const auto part : iov) flat.insert(flat.end(), part.begin(), part.end());
69 sent.push_back(std::move(flat));
70 }
71};
72
73/** @brief A `PATH` TLV over @p segs — RFC-0018 packed segment records, `opt.PL` clear. */
74std::vector<std::byte> path_tlv(std::initializer_list<std::string_view> segs) {
75 std::vector<std::byte> body;
76 for (std::string_view s : segs) (void)tr::wire::emit_path_segment(body, s);
77 std::vector<std::byte> out;
78 tr::wire::emit_tlv(out, type_t::PATH, opt_t{}, body);
79 return out;
80}
81
82/** @brief A one-byte `VALUE` TLV carrying @p v. */
83std::vector<std::byte> value_tlv(std::uint8_t v) {
84 const std::byte b{v};
85 std::vector<std::byte> out;
86 tr::wire::emit_tlv(out, type_t::VALUE, opt_t{}, std::span<const std::byte>(&b, 1));
87 return out;
88}
89
90/** @brief `FWD[.pl]{ VALUE op, PATH dst, PATH src, VALUE payload }` (RFC-0004 §B child order). */
91std::vector<std::byte> fwd_write(std::initializer_list<std::string_view> dst,
92 std::initializer_list<std::string_view> src) {
93 std::vector<std::byte> body = value_tlv(static_cast<std::uint8_t>(fwd_op_t::WRITE));
94 for (const auto& part : {path_tlv(dst), path_tlv(src), value_tlv(0x2A)}) {
95 body.insert(body.end(), part.begin(), part.end());
96 }
97 std::vector<std::byte> out;
98 tr::wire::emit_tlv(out, type_t::FWD, opt_t{.pl = true}, body);
99 return out;
100}
101
102/** @brief The `kind` of a `FWD{REPLY}` frame, or `std::nullopt` if @p frame is not one. */
103std::optional<reply_kind_t> reply_kind(std::span<const std::byte> frame) {
104 const auto tlv = tr::wire::tlv_node_t::over(frame);
105 if (!tlv) return std::nullopt;
106 // Child order is `VALUE op, PATH dst, PATH src, VALUE kind, …` — the kind is child 3.
107 const tr::wire::tlv_children_t kids = tlv->children();
108 const auto kind = std::ranges::next(kids.begin(), 3, kids.end());
109 if (kind == kids.end() || (*kind).payload().size() != 1) return std::nullopt;
110 return static_cast<reply_kind_t>((*kind).payload()[0]);
111}
112
113} // namespace
114
115int main() {
116 bool ok = true;
117 graph_t g;
118 tr::net::fwd_router_t router(g);
119
120 // One child, named "b"; one local vertex, at /sensor/temp. Both spellings below are
121 // ordinary paths — nothing marks one of them as "remote".
122 recording_link_t to_b, to_client;
123 if (!router.add_child("b", to_b) || !router.add_child("cli", to_client)) {
124 std::fprintf(stderr, "route_terminus_or_forward: add_child failed — nothing registered\n");
125 return 1;
126 }
127 (void)g.register_vertex(path_t("/sensor/temp"), role_t::STORED_VALUE);
128
129 // Arm 1 — the leading route segment names a child: FORWARD. Nothing is written here.
130 router.on_frame("cli", fwd_write({"b", "sensor", "temp"}, {}));
131 check(ok, to_b.sent.size() == 1, "\"b\" names a child, so the frame left on that child");
132 check(ok, !g.read(path_t("/sensor/temp")).has_value(),
133 "and the identically-named LOCAL vertex was not touched");
134
135 // Arm 2 — the leading route segment names no child: TERMINUS. The same trailing route
136 // segments are now an address, and the write lands in this node's graph.
137 to_b.sent.clear();
138 router.on_frame("cli", fwd_write({"sensor", "temp"}, {}));
139 check(ok, to_b.sent.empty(), "\"sensor\" names no child, so nothing was forwarded");
140 check(ok, g.read(path_t("/sensor/temp")).has_value(),
141 "this node was the terminus: the op applied to the LOCAL vertex");
142
143 // Arm 3 — terminus, but the address resolves to nothing. The refusal is ADDRESSED, not
144 // dropped: a REPLY with kind=ERROR goes back down the src the request accumulated.
145 to_client.sent.clear();
146 router.on_frame("cli", fwd_write({"no", "such", "vertex"}, {"app"}));
147 check(ok, to_client.sent.size() == 1,
148 "an unresolvable dst ANSWERS on the inbound link — it is not swallowed");
149 check(ok, !to_client.sent.empty() && reply_kind(to_client.sent[0]) == reply_kind_t::ERROR,
150 "and the answer is FWD{REPLY} kind=ERROR, so the caller sees a status, not a timeout");
151
152 std::printf(
153 "one test, two behaviours: \"b\" is a child (forward), \"sensor\" is not (terminus)\n");
154 return ok ? 0 : 1;
155}
See also: fwd-router module · addressing · communication flows · the source route · the child table.