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

tr::net::can

can.hpp

transport plane

header-elided framing

tr::view::can_frame_count / can_frame_at

view_can.hpp

L1

multi-frame reassembly

tr::net::can_reassembly_t

can_reassembly.hpp

transport plane

SocketCAN binding + raw-frame seam

tr::net::transport_can, can_link_t, socketcan_link_t

transport_can.hpp

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

version

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.

node

13

0–8191

The originating node id.

endpoint

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 a NAME segment, not a [N] index — NAME excludes [ ], 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↔path map keys on (which controller the frame arrived on) + (node | endpoint)/net/can/<bus>/…, so two buses carrying the same node id 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

can_frame_mode_t::CLASSIC

8 bytes

Classic CAN 2.0.

can_frame_mode_t::FD

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 group is_complete() only when every index 0..count-1 is 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 valueid path; the lean frames that follow are values.

  • Rope group / manifestgroup-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 / presenceslice_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.

  • Directedtarget_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-peer FWD forwarding 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 = 0xAD

1

1

format version = 0x02

2

1

flags (0x01 = group)

3

1

reserved, must be zero

4

4

can_id (u32 LE; a 29-bit value)

8

4

group_total_len (u32 LE; 0 if single-value)

12

2

slice_count (u16 LE; 1 if single-value, 0 = hello)

14

2

target_node (u16 LE; 0xFFFF = undirected broadcast)

16

2

path_len (u16 LE)

18

path_len

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.

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):

  1. The frame is split by can_frame_count / can_frame_at into data-field windows.

  2. 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.

  3. The lean data frames follow, one per window, on consecutive endpoint slots starting at the first data slot (slice_can_id address-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)

max_pending — evict oldest

rx_ttl, swept on every inbound frame

dropped_rx()

reassembly groups

max_groups — evict oldest

rx_ttl, swept on every inbound advertise

dropped_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-in0 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.

  1. A slice parked before its advertise. It is re-driven into whichever group later claims its slot. Still open; bounded by the rx_ttl age-out.

  2. 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):

  1. A bare peer dst (/n5/a/b). A peer segment is reachable only through its connection’s mount run — resolve_mount_segs matches net/<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 is dst=/net/can/can0/n5/a/b.

  2. A global cross-bus peer scan. Resolution is not “ask each bus child whether this is its peer”. child_registry_t::resolve_peer resolves 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_ttl of 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_peerbus_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 CANcore/tests/transport_can_test.cpp pairs 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.cpp covers 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 vcan0core/tests/transport_can_vcan_test.cpp drives two socketcan_link_t over 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 bare CAN_RAW adversary 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 to write_raw is 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 when vcan0 cannot bind, so the required gates never depend on kernel CAN; the dedicated can-vcan-e2e workflow sets vcan0 up so the socket path runs for real.

Pitfalls

Rule

Failure mode it prevents

Which frames cross the can_link_t seam is decided once, at the seam.

Two ports of the same seam drift on what counts as traffic. This is not hypothetical: twai_link_t filtered RTR and bounded classic length while socketcan_link_t did neither, so on Linux a remote-request frame reached the reassembler as a data slice whose DLC promised bytes it never carried.

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 (origin, ts) + index, and origin is receiver-derived, never read off the wire.

Grouping by ts alone merges the slices of two publishers that emit at the same timestamp into one corrupt rope.

Trailing-slice loss is only detectable with a declared count.

An implementation that infers N from the highest index observed reports a 100-slice group as complete when index 99 was dropped. The advertise manifest’s slice_count is what makes the group total.

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 decode_advertise desynchronizes for every subsequent manifest, not just the padded one.

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 (CAN_RAW_RECV_OWN_MSGS, or a second local socket) makes a node its own peer and pollutes the last-heard table.

A peer name is n<node-id>, decimal, no leading zeros.

An implementation that accepts n05 or N5 resolves two names to one peer, and a route grown into src fails to round-trip as the inbound NAME the reply is matched against.

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.

:stats is not in the field namespace.

An implementation that publishes bus-off or error counters by writing /net/can/<bus>:stats gets tr::schema::not_found on every emission and its subscribers never see the event.