Reference 14 — CAN transport¶
In one paragraph
CAN is a header-elided transport (ADR-0022 — transport framing modes: full-TLV, header-elided, advertise+id-match):
the CAN frame’s native identity — its 29-bit ID — is the path, so the 4-byte
TLV header never rides the constrained bus and the existing CAN frames are
byte-unchanged (zero added overhead — the constraint that makes 100 ksps-over-CAN
feasible). The ID is structured — [protocol-version prefix | node | endpoint]
— and because lower numeric ID = higher bus arbitration priority, assigning
the ID also assigns real-time priority. A payload larger than one frame (8 bytes
classic, up to 64 CAN-FD) reassembles via libtracer’s own address-shift slicing /
advertise+id-match keyed by (origin, ts) + index → rope, not ISO-TP
(origin is receiver-derived — on this transport, the peer identity in the frame id).
The identity↔path map lives inside transport_can, is dynamic, and
self-establishes decentrally from in-band advertise frames on (re)connect —
there is no gateway or orchestrator role (ADR-0030 — CAN transport: a dynamic in-transport map, a structured 29-bit ID, advertise+id-match reassembly,
13-network-formation §self-healing).
This document has two halves. Everything up to §The SocketCAN binding
describes the framing layer — the host-testable, syscall-free codecs that
define what goes on the wire: the structured identifier, the two framing modes,
the reassembly model and the advertise frame. That layer touches no socket and no
kernel, so a second implementation can reproduce it from this page alone. The
binding section describes the SocketCAN binding, which drives those codecs
over a live PF_CAN socket and is the reference implementation’s realization of
them.
The reference-implementation symbols are:
Concern |
Symbol |
Header |
Layer |
|---|---|---|---|
29-bit ID + advertise codec |
|
|
transport plane |
header-elided framing |
|
|
L1 |
multi-frame reassembly |
|
|
transport plane |
SocketCAN binding + raw-frame seam |
|
|
transport plane |
The structured 29-bit extended ID¶
A CAN 2.0B extended frame carries a 29-bit identifier. libtracer gives it three fields, most-significant first:
bit 28 bit 0
└─ version(4) ─ node(13) ─ endpoint(12) ─┘
Field |
Width |
Range |
Meaning |
|---|---|---|---|
|
4 |
0–15 |
Protocol-version prefix — discovery-layer versioning on CAN (a distinct ID prefix per protocol version, CONTEXT.md Discovery-layer versioning), not a per-frame version field. |
|
13 |
0–8191 |
The originating node id. |
|
12 |
0–4095 |
The per-node endpoint slot (the path leaf the map resolves). |
encode_can_id / decode_can_id are exact inverses for any in-range value; an
input beyond 29 bits decodes to nullopt. A distinct version prefix is a
disjoint protocol band: a receiver ignores every frame whose prefix is not its
own, so two protocol generations share one wire without interpreting each other.
Bit widths are a reference-impl modeling choice
ADR-0030
pins the layout (version | node | endpoint) and the priority semantics but not
the exact widths. The 4 / 13 / 12 split is the reference implementation’s choice:
4 version bits cover protocol generations comfortably, while 13/12 balance node
count (8192) against endpoints-per-node (4096). Other deployments may repartition
the lower 25 bits; the version prefix and the priority ordering are the invariants.
ID assignment is priority assignment¶
CAN arbitration is dominant-bit — when two nodes transmit simultaneously, the
frame with the numerically lower ID wins the bus. Because node is more
significant than endpoint, a lower node id outranks a higher one regardless of
endpoint, and a lower version prefix outranks everything in a higher one. So the
path→ID assignment the map performs is also a real-time priority assignment — a
CAN-specific knob exposed through the identity↔path map, with no side channel.
Two or more CAN buses on one node¶
The 29-bit ID deliberately carries no bus field — the bus is implicit (it is the
wire the frame arrived on). A node with several CAN controllers (e.g. can0, can1)
therefore distinguishes them in the path, not in the ID: under the path-as-route
model (RFC-0004 — remote operation addressing,
ADR-0027 — a transport and each connection within it is a first-class / vertex)
each bus is its own connection vertex under the CAN module:
/net/can/
├─ can0/ :settings{ bitrate } :stats{ bus_off, err_count } :acl ← controller can0
│ └─ n<node>/… ← peers audible on bus can0, synthesized per read
└─ can1/ :settings{ … } :stats{ … } :acl ← controller can1
└─ n<node>/…
A connection vertex is mounted and routed at
/net/<module>/<name>, so the module segment (can) groups a node’s buses and the connection NAME (can0) identifies one controller.The bus identifier (
can0,can1) is aNAMEsegment, not a[N]index —NAMEexcludes[], and the bus is a distinct identity, not a slice (segment-[N]indices stay reserved for address-shift data slicing below, never for bus addressing). The default name is the controller interface (à la SocketCAN), but it MAY be semantic (/net/can/powertrain).Each bus is its own vertex with independent
:settings,:stats(bus-off / error counters), and:acl— two controllers are two hardware identities, exactly the “distinct lifecycle ⇒/vertex” rule of ADR-0027.
Warning
:stats is intended, not implemented — including in the tree diagram above. A field read or
write to :stats answers ERROR{tr::schema::not_found}; the field namespace the dispatcher recognises is {subscribers, acl, children, settings, schema, identity}. A recognised name can still answer NOT_FOUND when the facet is empty, or SCHEMA_NOT_FOUND for a spelling it does not accept (bare :subscribers requires [N]) or for a facet deliberately absent (:identity with no keypair, RFC-0011 §C.3) — so the set is a namespace, not a list of things that read. Whether per-transport :stats should
exist at all is open (#584); the diagram is kept because the design
intent — per-bus counters on a per-bus vertex — is what that decision is about. :settings and
:acl in the same diagram are real facets, but the per-knob spelling is not: bare :settings
serves the settings container, while a selector naming a bus knob (:settings.bitrate) is outside
the flat QoS-knob namespace and answers SCHEMA_NOT_FOUND on both read and write. Link parameters
reach a bus through the config of the write that creates it.
The
identity↔pathmap keys on (which controller the frame arrived on) + (node|endpoint) →/net/can/<bus>/…, so two buses carrying the samenodeid never collide — the bus segment disambiguates them while the ID stays compact.read("/net/can")enumerates the buses (vertex enumeration, reference/04), so an orchestrator discovers a node’s bus count with no special API.
Address-shift slice IDs¶
A multi-frame payload is spread across consecutive endpoint slots of the same
node — endpoint[0..N] — so slice index simply shifts the endpoint sub-field
(slice_can_id(base, index)). The whole group therefore stays in one
version|node band and keeps a single arbitration-priority class. This is exactly
ADR-0011 — address-shift totality is opt-in
address-shift slicing applied to the CAN ID. A group whose base slot plus its slice
count would overrun the endpoint field is not representable on that node; the
allocator wraps back to the first data slot, leaving the control slot free.
Framing modes: classic vs CAN-FD¶
can_frame_count(payload, mode) and can_frame_at(payload, mode, i) chop one
logical payload (a view_t) into the CAN data-field windows that carry it:
Mode |
Max data field |
Notes |
|---|---|---|
|
8 bytes |
Classic CAN 2.0. |
|
64 bytes |
CAN-FD; valid DLC sizes are 0–8, 12, 16, 20, 24, 32, 48, 64. |
The split is zero-copy — each window is a subview() over the source segment,
mirroring the existing view/rope primitives (08-views-and-ownership);
no payload byte is copied. A payload that fits one frame yields a single window; a
larger one yields a sequence whose tail window holds the remainder, and the far side
chains the windows back into a rope_t via can_reassembly_t.
There is no splitter object: each window is a pure function of the payload length, the mode and the index, so the pair of free functions is the whole API. Nothing is stored, nothing is allocated, and the split cannot fail.
On-wire, a CAN-FD frame can only be a valid DLC length, so an in-between window is
padded up to the next legal size — can_fd_dlc_round_up(len) exposes that lattice.
The framing windows themselves stay the exact logical chunk lengths (so they remain
zero-copy subviews); applying the DLC padding is the SocketCAN binding’s job.
Multi-frame reassembly — address-shift, not ISO-TP¶
can_reassembly_t reassembles a payload that spanned several CAN frames. Each
frame is a slice; slices are grouped by the in-flight identity
(origin, ts) — the same slice-group key used everywhere else
(03-addressing §the slice-group key,
CONTEXT.md Address-shift slicing) — and ordered by index.
origin is a receiver-side identity, not a wire field: v1 defines no
delivery-borne producer id, so on this header-elided transport the binding derives
it from how the slice arrived — the link-local peer identity read off the frame id
(can_reassembly_t keys it as (node, base-endpoint)). There is no dedup or
revisit state anywhere in the stack; the key groups slices, nothing else. The base
endpoint recurs — the 12-bit space wraps — so that key is unambiguous only under the
binding-lifetime invariant described in A re-issued endpoint run retires whatever still
claims it, which is what stops a reused base from merging two unrelated payloads.
assemble() chains the slices, in index order, into a rope_t — zero copies.
This deliberately reuses libtracer’s one reassembly model rather than bolting on ISO-TP (ADR-0030): the same mechanism that “spans a 9-byte elided CAN sample → a GB advertised rope group” serves CAN, UDP scatter-gather, and QUIC alike.
Out-of-order and missing-fragment handling:
Out of order. Slices may arrive in any order; they are stored by index and emitted in ascending order at assembly.
Interior gap. A missing slice below the highest received index is detected by
has_interior_gap()even before the count is known.Totality is opt-in.
set_expected_count()(the advertise manifest’s slice count) makes the groupis_complete()only when every index0..count-1is present, and makes a dropped trailing slice detectable. Without it, a trailing drop is invisible — exactly ADR-0011 totality-opt-in.assemble()returns a rope only once the group is complete.
Layer placement of the reassembly buffer
A multi-frame reassembly buffer is a transport-plane concern, not an L0 one,
because it composes L1 views into a rope exactly as any transport does. Placing it
at L0 would make an L0 type reference the L1 rope_t it assembles, which the layer
model forbids (ADR-0048 — one wire-grammar core behind a chunk cursor).
Its bounding behaviour follows the same discipline as every other libtracer
resource: structure is drawn from an injected resource, the live group count is
bounded by configuration, and overflow evicts the oldest group and increments a
dropped_groups counter. A constrained node therefore degrades by a bounded drop
rather than by unbounded growth, and no magic number appears anywhere in the buffer.
A count bound alone does not reclaim a group that will never complete, which is
what a lost data slice leaves behind: erase() is reached only after is_complete().
So the buffer also ages out. It holds no clock of its own — the caller stamps it
(set_now) and sweeps on a cadence it chooses (sweep_stale), keeping the buffer a
pure framing primitive exactly as it has no allocator of its own. An age-out ticks the
same dropped_groups counter an eviction does: one counter for “a group’s buffered
slices were reclaimed before delivery”, whatever forced it.
The in-band advertise frame and the dynamic map¶
The identity↔path map is dynamic config held inside transport_can — not
static, not held by a privileged node. It self-establishes from in-band
advertise frames: an advertise is a control frame that establishes a
header-elided binding at runtime, mapping a CAN ID to a libtracer path, after which
the lean, id-matched data frames carry only payload (the
discovery_static/discovery_mdns-shaped “full caps sets up non-interactive
bindings” split, CONTEXT.md Framing modes).
advertise_t has two forms, distinguished by the group flag:
Single value —
id ↔ path; the lean frames that follow are values.Rope group / manifest —
group-id ↔ (path, slice structure); the advertise carries the slice count and total length, and the lean id-matched slice frames that follow are chained into a rope by id+index. This is the advertise+id-match generalization (CONTEXT.md Advertise + id-match), the manifest ADR-0011 otherwise carries statically.
Two further forms serve the ADR-0044 — transport-peer enumeration is stateless and synthesized from live traffic peer plane (both transport-internal framing in the same advertise family):
Hello / presence —
slice_count == 0: binds nothing and precedes no data; it only announces “this node is on the bus” (plus its identity path). Emitted once at join; any subsequent frame refreshes liveness.Directed —
target_node != 0xFFFF: the group is addressed to ONE peer. Every other node recognizes and consumes its data slices but never reassembles or delivers them — per-peer unicast semantics on a broadcast medium, which is what makes transparent per-peerFWDforwarding possible.
On-wire layout (little-endian, an 18-byte header + the path bytes; format 0x02
widened the v1 header with the explicit target_node field):
Offset |
Size |
Field |
|---|---|---|
0 |
1 |
magic = |
1 |
1 |
format version = |
2 |
1 |
flags ( |
3 |
1 |
reserved, must be zero |
4 |
4 |
|
8 |
4 |
|
12 |
2 |
|
14 |
2 |
|
16 |
2 |
|
18 |
|
path bytes (UTF-8 libtracer path) |
encode_advertise / decode_advertise round-trip this; decode rejects a wrong
magic, an unknown format version, a non-zero reserved byte, a path_len beyond the
declared bound, or a truncated buffer (nullopt = need more / malformed), with an
overflow-safe length check.
Self-healing (no coordinator)¶
Because the map lives inside transport_can and is rebuilt from advertise frames,
recovery is local and automatic (13-network-formation
§self-healing): on (re)connect a node re-announces its own mappings, so a
rejoining leaf or a downed forwarding hop costs only the paths through it. There is
no central authority to lose — the map is never a single point of truth, and no
node holds another node’s wiring. A constrained CAN leaf stays dumb (a compile-time
CAN-ID scheme); the map machinery runs in transport_can on whatever node hosts it.
The ws/UDP generalization — the route-handle¶
CAN’s identity↔path map is mandatory because the ID is the path. On a
full-TLV transport (ws/UDP) the same idea is opt-in compaction: the
route-handle (05-protocol-tlvs.md §route-handle frames,
RFC-0004 §E.1, ADR-0035 — implementing RFC-0004
slice 4) is a per-link u16 label that aliases an established delivery route,
advertised in-band exactly as a CAN binding is — but with the label swapped each
hop (MPLS-style), since a ws label is meaningful only on its link. The mechanics
mirror this section one-for-one: an ADVERTISE frame establishes label ↔ route,
lean COMPACT frames then carry only the label + value, a stale label is dropped
with a HANDLE_NACK, and re-advertise on (re)connect is the self-heal. The
difference is policy, not mechanism: CAN always labels (no route fits in 8 bytes);
ws labels only flows whose SUBSCRIBER.qos_settings.delivery_compact is set, so
a ws node forwarding one-shot/cold traffic holds zero label state. The ws table
lives in tr::net::route_handle_t, owned by fwd_router_t.
The SocketCAN binding (transport_can)¶
tr::net::transport_can is a transport_t that drives the framing above over a
real Linux CAN bus. A forwarder hands it a complete libtracer frame via send();
the byte-exact frame surfaces at the peer’s receiver.
The can_link_t seam (testable without kernel CAN)¶
The raw frame I/O sits behind a small seam, can_link_t (write_raw(frame) + an
on_receive callback + start()), so the transport never touches a socket directly.
The seam is two-phase. Constructing a link only OPENS it — the link must not begin
reading. Inbound frames start flowing at start(), which the owner calls after
on_receive has been registered, so there is no window in which a link reads a frame
with no sink to hand it to. transport_can drives both phases for the link it owns
(register, then start), so a caller that hands its link to the transport does nothing
extra; a caller that keeps a link to itself calls start() explicitly. Nothing is
buffered by libtracer to make this work and nothing is lost by it: the kernel socket
buffer (SocketCAN) and the driver RX queue (TWAI) hold what arrives between open and
the first read. Egress does not wait on start() — write_raw is live as soon as the
link is open.
Which frames cross the seam is the seam’s rule, not each link’s. Ingress admits
only 29-bit data frames — can_rx_admissible(extended, remote, error) — because
the extended identifier is the path, so an 11-bit standard frame carries no
decodable identity, a remote-transmission request carries a DLC but no data bytes,
and an error frame is a controller status report. Egress admits only a length that
fits the mode’s data field — can_tx_admissible(frame) over can_max_len(fd), the
seam’s adapter onto the L1 widths. Every port of a physical bus calls both,
decoding the flags from its own driver’s representation; the verdict is reached in
one place, so two ports cannot disagree about what is real traffic. (The in-memory
test links are exempt by construction: their carrier cannot express RTR, an 11-bit
identifier, or an error flag, so the ingress rule has nothing to judge.)
socketcan_link_t— the production impl:socket(PF_CAN, SOCK_RAW, CAN_RAW),CAN_RAW_FD_FRAMESenabled best-effort (a classic-only controller works unchanged), bound to a named interface (vcan0/can0), with a receive thread — spawned atstart(), not at construction — that translates each kernelcan_frame/canfd_frameinto a mode-agnosticcan_frame_data_t. Platform selection is a build-system concern, not an in-source#ifdef: the Linux translation unit compiles on Linux and a no-op stub — whoseok()stays false, so a port such as an ESP-IDF TWAI link can supply the realcan_link_t— compiles everywhere else, keeping sanitizer and non-Linux builds clean. Concurrency mirrorstransport_ws: serialized writes, the fd reset under the write lock before close, a bounded receive timeout polling the stop flag, destructor does stop→join→close.An in-memory fake link (in
core/tests/transport_can_test.cpp) pairs two transports on one bus with no syscalls — this is what makes the binding fully testable in a plain container with novcanmodule.
Egress: advertise-then-data, with CAN-FD DLC padding¶
send(frame) is emitted as one group under a single lock (so concurrent sends
never interleave on the bus):
The frame is split by
can_frame_count/can_frame_atinto data-field windows.An advertise manifest is emitted first on the node’s control ID (
[version|node|0]— the lowest endpoint, hence highest bus priority, so the manifest out-arbitrates the data it governs). It is sliced into classic ≤8-byte windows even on an FD bus, so no DLC padding can perturb the far-side stream decoder. The manifest carries the exact total length and slice count.The lean data frames follow, one per window, on consecutive endpoint slots starting at the first data slot (
slice_can_idaddress-shift). In CAN-FD mode a short tail window is padded up the DLC lattice (can_fd_dlc_round_up) to a legal frame length; the pad bytes are zero.
Ingress: learn, reassemble, trim¶
The receive thread decodes each frame’s CAN ID, discards any frame outside its own
version band and any frame bearing its own node id (the self-echo guard). A
control-slot frame feeds the per-node advertise byte stream (decode_advertise
pops each complete manifest), which learns the id ↔ path binding and sets the
group’s expected slice count. A data-slot frame is reassembled by
can_reassembly_t, keyed by (node, base-endpoint) + (endpoint − base) index — all
derived from the CAN ID, so no per-frame origin/ts ever rides the bus. On completion
the slices are flattened and trimmed back to the advertised total length, which
is what undoes CAN-FD tail padding so the delivered frame is byte-exact. A data
frame that races ahead of its manifest (cross-ID arbitration) is held pending and
re-driven when the manifest lands.
Modeling choices of the binding
Advertise-per-send. The binding emits a fresh manifest for every
send(). It keeps the data plane correct and uniform (single value and multi-frame group are the same path) and makes DLC-padding trim unconditional. The steady-state advertise-once-then-reuse optimization (one binding, many lean values) is not realized; a learned binding persists past delivery and is retired only when a fresh advertise re-issues its endpoint run (below), which is also how it self-heals.Ordering. Correctness relies on per-bus in-order delivery of a group’s frames (which a single producer gets on CAN); the pending-data buffer covers control/data cross-ID reordering.
Ingress is bounded in count and in age¶
Both receive-side buffers are reclaimable, because on a bus that drops frames both have a residue that nothing else frees:
Buffer |
Count bound |
Age bound |
Counter |
|---|---|---|---|
pending data slices (awaiting an advertise) |
|
|
|
reassembly groups |
|
|
|
A fourth counter, dropped_stale_binding(), is not a bound either: it counts data slices
refused because the binding they resolved to belongs to a prior lap of the producer’s
endpoint allocator — see A binding from a prior lap is refused, never welded below.
A group is also reclaimed, on the same counter, when a fresh advertise re-issues the endpoint run its binding held — see A re-issued endpoint run retires whatever still claims it below. That one is not a bound at all but a correctness rule; it shares the counter because the counter’s meaning is “a group’s buffered slices were reclaimed before delivery”, whatever forced it.
The count bounds are opt-in — 0 means unbounded, host-bounded per RFC-0006,
the same policy as the stream servers’ max_peers — and both, along with the pmr
resource the buffers draw from, arrive through the connection’s own config door
(max_groups, max_pending, rx_ttl_ms; the resource is injected at
factory-registration time, since a pointer cannot ride a config TLV). The age
bound is always live, so it is what holds under the default configuration. It is not
an independently invented number: left at 0, rx_ttl tracks the configured
peer_ttl, on the reasoning that RX state a peer would have completed is dead once
that peer is itself considered gone.
A third reclamation shares those counters: an inbound slice whose bytes the ingress
backend refuses (rx_backend, the companion injection to the pmr resource — that one
bounds the reassembly structure, this one the slice bytes) drops the whole
group, ticking dropped_rx() for the slice and dropped_groups() for the group.
Never a placeholder: the reassembly buffer counts entries without inspecting their
length, so an empty stand-in would satisfy is_complete, chain into the rope, and be
trimmed to a byte-wrong short frame that the receiver could not tell from good data.
Egress has the matching counter, dropped_tx(): a send that never reached the bus
(no storage for the payload, a payload that split into no window, a group needing more
consecutive endpoint slots than kCanMaxGroupSlices, or a manifest that could not be
encoded) is counted rather than silently discarded.
A group is reserved before it is advertised¶
The endpoint window is the scarce resource, and a group occupies a run of
consecutive slots in it. The manifest is a promise of slice_count slices, so the run
is reserved before the manifest goes out: a group that fits at no base is refused
whole and counted, and nothing is said on the bus. Advertising first and discovering
the shortfall mid-loop is what leaves every listener holding a group that can never
complete. The bound is derived from the ID field widths (kCanMaxGroupSlices =
kEndpointMax minus the reserved control slot), so it moves with the wire and is never
a chosen number. Retracting an already-emitted manifest was the alternative and was
declined: it is a second wire concern — a control-frame semantic every peer must
implement, itself lossy on the very medium that lost the tail slices — where the
capacity is a purely local fact the sender already holds.
A re-issued endpoint run retires whatever still claims it¶
The endpoint window is not only scarce, it wraps: alloc_base resets to the first
data slot when a reservation runs off the end, so a base recurs — routine, not
exceptional. Two receive-side structures key on that base, and both aliased once a run
was re-issued: the learned-binding map resolves a slice by first-match over [base, base + slice_count) in ascending base order, so a stale, wider, lower-numbered range
shadowed the live binding; and the reassembly group key is (node, base-endpoint),
so a recurring base merged slices left over from an incomplete group into the fresh
one. The second is the worse one: is_complete could be satisfied by a mix of old and
new slices, so a byte-corrupted frame was delivered as valid — silent cross-talk between
two unrelated payloads, not a crash.
Both close on one invariant, enforced when an advertise is learned: at most one binding
may claim an endpoint slot of a node, and a reassembly group lives exactly as long as the
binding that feeds it. A fresh advertise retires every same-node binding whose run
overlaps the one it claims, and discards the group each was feeding — reclaimed and
counted on dropped_groups(), exactly as an age-out or an eviction is. Because a group
is only ever fed through a live binding, and its key is derived from that binding’s base,
two groups can share a key only if two bindings share a base, which this makes impossible.
The overlap test is arithmetic on the CAN ID’s own endpoint field: no epoch, no generation
counter, and no bound that is not the wire’s.
Two residues this does not reach, both rooted in the same fact: a data frame carries only the CAN ID, so a slice from a previous lap is byte-indistinguishable from one belonging to the group now claiming those slots.
A slice parked before its advertise. It is re-driven into whichever group later claims its slot. Still open; bounded by the
rx_ttlage-out.A stale binding no re-issue overlapped, fed by frames whose own advertises were lost. The retire-on-re-issue rule fires only when a new run overlaps the old one. A binding whose run is skipped over survives; if the advertises for the groups that later occupy nearby slots are themselves lost on the bus, their data slices resolve first-match to that surviving binding, fill its indices, and complete its stale group. Two different payloads are then welded into one frame and delivered upstream as valid. No slice is ever parked, so this is a distinct mechanism from (1) rather than a restatement of it. Closed by the lap test below — refused and counted, not welded.
A binding from a prior lap is refused, never welded¶
Overlap arithmetic answers “is this run aliased?”, and correctly answers no for a run nobody re-issued. It cannot answer the different question the weld turns on: is there still an advertise standing behind this binding? Once the producer’s allocator has come round, a skipped-over binding is a promise about slices emitted a whole revolution ago that, being incomplete, never will arrive — and the manifest-less data now landing in those slots resolves straight to it.
The receiver decides that from state it already holds, with no wire change. alloc_base
issues strictly ascending bases and wraps to the first data slot, so an advertise whose
base does not exceed the last base that node placed is the wrap, observed. Every
binding of that node is then flagged as belonging to a prior lap, and a data slice
resolving to a prior-lap binding takes the same disposition as a refused-backend slice or a
DLC-0 one: the whole group is discarded (dropped_groups()) and the slice is counted by
name on dropped_stale_binding() — a distinct cause, never folded into dropped_rx().
The stale binding is deliberately kept, not erased. Erasing it would send its slices to the pending park, where a later advertise for that run could re-drive them into a fresh group — residue (1) above, a distinct mechanism this does not conflate with. Keeping it makes every stale slice resolve, be refused, and be counted, one tick each.
What it costs: one std::uint16_t per remote node (folded into the per-node control-stream
entry, so no second map) and one flag per binding, in padding the binding already carried —
zero added bytes per binding. On the common path, one already-loaded bool test per
data slice; no lookup, no clock, no allocation. What it costs a sender: a group whose
advertise is lost on slots a prior-lap binding still holds is now refused rather than
delivered corrupt — a delivery traded for a counted drop.
ADR-0077
records why the alternative — binding group identity into the data frames — stayed
declined: the 29 bits are fully spent by version|node|endpoint, so it costs endpoint bits,
an advertise format-version bump, and a mixed-version decode matrix. The lap is inferred
receiver-locally from the allocator’s own observable behaviour, so a mixed-version bus needs
no compatibility rule at all.
Eviction is not a substitute for correct keying
Aging and eviction bound memory; they do not make a stale binding safe to reuse. The deterministic property is the invariant above — a binding, and the group it feeds, are retired at the moment their run is re-issued, not whenever a timer happens to fire. ADR-0077 records the decision and, in its implementation-status section, why the producer generation it also proposes has not been implemented: it is redundant against this invariant and cannot reach the parked-slice residue either.
Peer enumeration and transparent per-peer forwarding¶
Two forms do not route, and both are easy to write by mistake (RFC-0014 S2a, ADR-0061):
A bare peer
dst(/n5/a/b). A peer segment is reachable only through its connection’s mount run —resolve_mount_segsmatchesnet/<module>/<name>first and resolves the peer as the segment after it. A bare/n5/…matches no mount, so it falls through to the terminus and is not forwarded at all. The routable form isdst=/net/can/can0/n5/a/b.A global cross-bus peer scan. Resolution is not “ask each bus child whether this is its peer”.
child_registry_t::resolve_peerresolves against the multi-peer child the frame was addressed through, so two servers’ same-named peers stay distinct and a peer is never reachable through the wrong module.
A CAN bus reaches many peers over one wire, so transport_can also implements
the kind-neutral tr::net::bus_link_t capability (transport_t::bus()), which is
how a client of the node holding the bus enumerates the currently-reachable
peers and forwards through to them — with hard statelessness guarantees
(ADR-0044):
No peer ever creates a vertex — on this node or any other. The listing is synthesized per read; nothing persists in the graph; a peer’s reboot mutates no listener’s tree.
The only peer state is a last-heard table inside the transport: refreshed by every valid same-version frame a peer emits (seeded by the join-time hello advertise) and expired after
peer_ttlof silence. Insert-only, one entry per distinct node id ever heard — the same policy as the identity↔path map — so it grows with the bus population (structurally bounded by the 13-bit node-id space), never per request or per frame, and no artificial capacity is hard-wired (memory policy stays the host’s).The transit node keeps zero per-request state: forwarding rides the RFC-0004 frame-carried routes (
dst-shrink /src-grow) unchanged.
Enumeration. Peers appear as n<node-id> (decimal, no leading zeros — the
stable identity the structured 29-bit ID carries; collision-safe within the
bus). A read of the connection vertex’s :children[] (e.g.
read("/net/can/can0:children[]"), locally or via a remote FWD{READ}) serves a
POINT whose children are POINT{NAME n<id>} members — a snapshot of who is
currently audible, wired through the vertex’s on_children handler by
transport_vertex_t for any link whose bus() is non-null.
Forwarding. Each listed name doubles as a routable next-hop segment — on this
transport and on every bus kind — ws/tcp bus sessions are named p<slot> (the
enumerable⇒addressable invariant, ADR-0073 §1): once a
FWD’s leading dst segments have matched this connection’s mount run
net/<module>/<name>, the router resolves the segment that follows it as a peer
within that endpoint’s own table (child_registry_t::resolve_peer →
bus_link_t::peer_link), yielding a directed per-peer endpoint — the group’s
advertise carries target_node, so on the broadcast bus only the addressed peer
delivers it. Inbound frames arrive tagged with the sender’s peer HANDLE
(bus_link_t::set_peer_receiver, #1294) — for this bus, its node id at a constant
generation, since an announce-census peer has no session to stamp — and the router
resolves it back to the peer NAME through bus_link_t::peer_name and uses that as
the hop’s inbound NAME. So the return route grown into src names the bus peer, and
the reply is itself a directed send. The whole round trip:
sequenceDiagram
participant C as client
participant T as transit T (CAN node 1)
participant P as peer P (CAN node 5)
participant Q as bystander Q (node 7)
P->>T: hello advertise (join) — last-heard table gains n5
C->>T: FWD{READ, dst=/net/can/can0, :children[]}
T-->>C: POINT{ POINT{NAME n5}, … } (synthesized, no vertices)
C->>T: FWD{READ, dst=/net/can/can0/n5/a/b, src=/reply-ep}
Note over T: match mount net/can/can0, then peer "n5"<br/>strip all four, grow src=/cli/reply-ep
T->>P: directed group (target_node=5): FWD{READ, dst=/a/b}
Note over Q: consumes slices, delivers nothing
Note over P: terminus: read /a/b<br/>inbound NAME = "n1" (sender's peer name)
P->>T: directed group (target_node=1): FWD{REPLY, dst=/cli/reply-ep}
T->>C: FWD{REPLY} forwarded over "cli"
The liveness model is deliberately minimal (design (b) of the ADR-0044
implementation note): a peer is “reachable” iff it has been audible within
peer_ttl — an idle-but-alive node ages out until it next speaks. Probe-on-read
(a discovery probe emitted by the :children[] read, answered within a bounded
window) is the recorded follow-on and is not implemented: it needs deferred
reply completion at the op_resolver_t terminus, which resolves synchronously.
Test surface¶
Docker-local, no kernel CAN —
core/tests/transport_can_test.cpppairs two transports over the in-memory fake link and asserts a multi-CAN-frame TLV round-trips byte-exact (classic and CAN-FD), advertise/map learning works, FD DLC padding is correct yet trimmed away, and the lifecycle is clean.core/tests/transport_can_peers_test.cppcovers the peer plane over the same fake link::children[]synthesizes exactly the audible peers, no peer vertex is created, and a directed group reaches its target while a bystander delivers nothing. Both run under the sanitizer builds.Real
vcan0—core/tests/transport_can_vcan_test.cppdrives twosocketcan_link_tover a kernel virtual-CAN device and asserts a byte-exact frame each way, and carries the seam-rule vectors that need a real socket to exist: a bareCAN_RAWadversary injects an RTR frame and an 11-bit standard frame alongside one admissible data frame, and exactly one crosses; an over-length classic frame handed towrite_rawis dropped rather than clamped (the stack smash it would otherwise cause is ASan’s to see — the kernel refuses the oversized write either way, so no unguarded link is distinguishable on the bus; the drop-vs-clamp choice is, and the vector pins it by giving the two frames distinct ids and payloads, so a truncated frame would be witnessed under its own id). It self-skips whenvcan0cannot bind, so the required gates never depend on kernel CAN; the dedicatedcan-vcan-e2eworkflow setsvcan0up so the socket path runs for real.
Pitfalls¶
Rule |
Failure mode it prevents |
|---|---|
Which frames cross the |
Two ports of the same seam drift on what counts as traffic. This is not hypothetical: |
The 29-bit ID carries no bus field. |
An implementation that packs a controller index into the ID collides with a deployment that repartitioned the lower 25 bits, and loses the property that one arbitration band belongs to one node. Two buses are two path segments, not two ID layouts. |
A slice group is |
Grouping by |
Trailing-slice loss is only detectable with a declared count. |
An implementation that infers |
A CAN-FD frame is trimmed back to the advertised total length. |
Delivering the padded frame hands the receiver DLC pad bytes as payload, so a frame that round-trips on a classic bus fails on an FD one. |
The advertise rides classic ≤8-byte windows even on an FD bus. |
Padding a manifest slice inserts pad bytes into the control byte stream, and the far side’s |
A frame whose version prefix is not the receiver’s is ignored, as is one bearing the receiver’s own node id. |
Interpreting another protocol generation’s frames yields garbage bindings; consuming self-echo ( |
A peer name is |
An implementation that accepts |
Peer listings are synthesized, never stored. |
An implementation that materializes a vertex per peer mutates every listener’s tree on a peer reboot, and leaks one vertex per node ever heard. |
|
An implementation that publishes bus-off or error counters by writing |