can — the header-elided CAN stack¶
In one paragraph
CAN gets a whole layer of its own because the bus is too narrow for a TLV header:
classic CAN carries eight data bytes per frame. libtracer elides the header
instead of shrinking it — the 29-bit extended CAN ID is the address — so a
data frame on the bus is payload and nothing else. A dynamic identity↔path map
inside the transport self-establishes through in-band advertise frames, an L1
splitter cuts one logical payload into id-matched frame windows with no copy, and
a reassembler chains the windows back into a rope on the far side.
What it does¶
The narrative — why the header is elided, how a map forms and heals with no gateway role, and what the bus looks like end to end — is reference §CAN transport. This page is the C++ surface, layer by layer.
The ID codec (tr::net::can) is pure and host-testable: it knows nothing
about sockets. A 29-bit extended identifier is a structured field — protocol
version, node, endpoint, and a per-group id — and encode_can_id /
decode_can_id / slice_can_id are the only places that layout exists. Because
it is pure, every ID edge case is testable without a bus.
The advertise codec (also tr::net::can) is the in-band control stream that
makes the map self-establishing. An advertise_t announces “this node, this
endpoint, this path”; the lean id-matched data frames that follow carry only
bytes. Endpoint slot 0 is reserved for the advertise stream, data groups start
at the next slot, and a peer that has been silent past the liveness window leaves
the enumeration on its own — there is no orchestrator to tell it to.
The splitter (tr::view::can_frame_count / can_frame_at) is L1, not
transport: it cuts a view into per-frame windows, each a subview over the same
segment, never a memcpy. It is a pair of free functions, not an object — a window
is a pure function of the payload length, the mode and the index, so there is no
table to hold and nothing to allocate. can_frame_mode_t selects the classic 8-byte or CAN-FD 64-byte data
field, and can_fd_dlc_round_up handles CAN-FD’s non-contiguous length ladder.
The reassembler (tr::net::can_reassembly_t) is the far side: (origin, timestamp) + index chains slices back into a rope_t — libtracer’s own
address-shift slicing, not ISO-TP. Its storage comes from an injected
std::pmr::memory_resource and its group count is bounded by configuration, so
exhaustion on a constrained node is a bounded evict-oldest drop with a counter,
never an allocation failure. The defaults — process heap, unbounded — are what a
host gets unless it says otherwise. The in-band spelling of those bounds — the
can-private max_groups / max_pending / rx_ttl_ms keys a :children[]
creation SPEC carries, alongside the bus identity ifname / node — is
connection config.
The binding (tr::net::transport_can) joins all of that to a real bus
through the can_link_t seam. socketcan_link_t is the production Linux
implementation, a PF_CAN raw socket with a receive thread. A different platform
implements the same seam — and inherits the same admission rule, because the rule
lives at the seam rather than in each port: can_rx_admissible (29-bit data frames
only; remote-request, 11-bit standard and error frames are not traffic) and
can_tx_admissible (a declared length must fit the mode’s data field). A port
decodes those flags from its own driver’s representation, but does not get to reach
its own verdict.
The seam is two-phase (#1186): constructing a link opens it, and a separate
start() is what begins reading — called after on_receive, so a link never reads a
frame it has no sink for. transport_can drives both phases for the link it owns, so
this is invisible to a caller that hands its link to the transport.
The TX pool (tr::net::can_tx_pool_t) exists for asynchronous links only.
A synchronous link needs none of it — the kernel copies the frame inside the
write call. A driver that queues the frame pointer and formats the buffer later,
possibly from a transmit-done interrupt, must keep the descriptor and payload
alive until completion; handing such a driver the writer’s stack storage is a
use-after-free with interrupt-context corruption. The pool is the storage the
link owns instead: fixed capacity chosen at construction, non-blocking acquire,
lock-free release. Deliberately mechanism-only — what to do when it is full
(bounded backpressure, a counted drop) belongs to the owning link, which pairs it
with its platform’s blocking primitive.
Pitfalls¶
An advertise is not a handshake. Nothing acknowledges it and nothing depends on having seen one before sending; a receiver that has not yet learned a mapping simply cannot attribute those frames yet, and learns on the next advertise.
Endpoint
0is reserved. Data groups begin at the first data endpoint; allocating group traffic to slot 0 collides with the control stream.CAN-FD lengths are a ladder, not a range. 8/12/16/20/24/32/48/64 — a payload that does not land on a rung is padded up, and the padding is on the wire.
The TX pool is per-link, not per-transport. Sizing it is a property of how deep the driver’s queue is, not of how many paths the node publishes.
API reference¶
The ID and advertise codecs¶
-
struct can_id_fields_t¶
The three structured fields of a header-elided CAN identifier.
Wire layout, most-significant first:
[version:4 | node:13 | endpoint:12](ADR-0030). Encoded into the 29-bit extended CAN ID by encode_can_id.Note
Lower numeric ID = higher bus arbitration priority (CAN dominant-bit arbitration). Because node is more significant than endpoint, a lower node id wins the bus over a higher one; assigning the version/node/endpoint values therefore also assigns real-time priority — a CAN-specific knob the identity↔path map exposes.
Public Functions
-
bool operator==(const can_id_fields_t&) const = default¶
Field-wise equality (value type).
-
bool operator==(const can_id_fields_t&) const = default¶
-
constexpr std::uint32_t tr::net::can::encode_can_id(const can_id_fields_t &f) noexcept¶
Pack structured fields into a 29-bit extended CAN ID.
Each field is masked to its width, so an over-range input cannot corrupt a neighbouring field; decode_can_id is the exact inverse for any in-range value.
- Parameters:
f – The version/node/endpoint fields to pack.
- Returns:
The 29-bit identifier (
0..kIdMax).
-
constexpr std::optional<can_id_fields_t> tr::net::can::decode_can_id(std::uint32_t id) noexcept¶
Unpack a 29-bit extended CAN ID into its structured fields.
- Parameters:
id – A candidate identifier.
- Returns:
The fields, or
std::nulloptifiddoes not fit in 29 bits (a value an extended CAN frame could never carry).
-
constexpr std::optional<std::uint32_t> tr::net::can::slice_can_id(const can_id_fields_t &base, std::size_t index) noexcept¶
Derive the CAN ID of slice
indexof an address-shift group.Address-shift slicing (CONTEXT.md): a logically large payload is spread across consecutive endpoint slots
endpoint[0..N]of the same node, so sliceindexsimply shifts the endpoint sub-field. The id stays in the same version/node band, so the whole group keeps one arbitration priority class.- Parameters:
base – The fields of slice 0 (its can_id_fields_t::endpoint is the base slot).
index – The zero-based slice index.
- Returns:
The packed 29-bit ID for the slice, or
std::nulloptifbase.endpoint + indexwould overflow the endpoint field.
-
struct advertise_t¶
A decoded in-band
advertiseframe — the identity↔path manifest.An advertise frame is a full-TLV control frame that establishes a header-elided binding at runtime: it maps a CAN advertise_t::can_id to a libtracer advertise_t::path, after which lean, id-matched data frames carry only payload. For a rope group it additionally carries the slice manifest (advertise_t::slice_count, advertise_t::group_total_len) — ADR-0011 address-shift slicing made dynamic. A rejoining node re-announces its own advertises, which is what makes the map self-healing (docs/reference/13 §self-healing).
Two special forms (ADR-0044, both transport-internal per ADR-0030):
hello / presence —
slice_count == 0: no binding is established and no data frames follow; the frame only announces “this node is on the bus” (and its identity path). Emitted at join; any advertise also refreshes liveness.directed —
target_node != kCanBroadcastNode: the group is addressed to ONE peer; every other node consumes its data slices without delivery. This is how a FWD forwarded onto the broadcast bus reaches exactly the peer its strippeddstsegment named.
On-wire layout (little-endian; a fixed kAdvertiseHeaderSize byte header, then the path):
offset
size
field
0
1
magic = kAdvertiseMagic
1
1
format version = kAdvertiseFormatVersion
2
1
flags (kAdvertiseFlagGroup)
3
1
reserved, must be zero
4
4
can_id (u32 LE; a 29-bit value)
8
4
group_total_len (u32 LE; 0 for a single value)
12
2
slice_count (u16 LE; 1 for a single value, 0 for hello)
14
2
target_node (u16 LE; kCanBroadcastNode = undirected)
16
2
path_len (u16 LE)
18
path_len
path bytes (UTF-8 libtracer path)
Public Functions
-
bool operator==(const advertise_t&) const = default¶
Field-wise equality (value type).
Public Members
-
std::uint32_t can_id = 0¶
The 29-bit ID this advertise binds.
-
bool group = false¶
True ⇒ a multi-frame rope group binding.
-
std::uint32_t group_total_len = 0¶
Total group payload bytes (0 if single-value).
-
std::uint16_t slice_count = 1¶
Slice count (1 = single value, 0 = hello).
-
std::uint16_t target = kCanBroadcastNode¶
Directed target node id, or kCanBroadcastNode for every node.
-
std::string path¶
The libtracer path the id maps to.
-
constexpr std::uint8_t tr::net::can::kAdvertiseMagic = 0xAD¶
Leading magic byte of an in-band advertise_t frame.
-
constexpr std::uint8_t tr::net::can::kAdvertiseFormatVersion = 0x02¶
On-wire format version of the advertise_t frame layout.
Version
0x02(ADR-0044) widened the header from 16 to 18 bytes with the explicittarget_nodefield (directed groups + the hello/presence form). The advertise family is transport-internal framing (ADR-0030 — not the L2 TLV spec), so the bump is a module-local change; all nodes of one bus deployment run one binding version.
-
constexpr std::size_t tr::net::can::kAdvertiseHeaderSize = 18¶
Fixed size, in bytes, of the advertise_t header that precedes the path.
-
constexpr std::uint8_t tr::net::can::kAdvertiseFlagGroup = 0x01¶
flagsbit: the binding is a multi-frame rope group, not a single value.Set when the advertise is the manifest for an address-shift group (
group-id ↔ (path, slice structure)); clear for a singleid ↔ pathvalue binding (CONTEXT.md Advertise + id-match).
-
constexpr std::uint16_t tr::net::can::kAdvertiseMaxPathLen = 1024¶
Largest
path_lena well-formed advertise may carry.A wedge-resistance bound for the control-stream decoder: a desynchronized stream fragment that happens to start with the magic/version signature could otherwise claim an absurd path length and stall resynchronization for up to 64 KiB of control traffic. Real libtracer paths on a CAN bus are far shorter; encoders MUST NOT exceed this and decoders reject beyond it (which lets advertise_prefix_plausible classify the prefix as garbage and resync).
-
constexpr std::uint16_t tr::net::can::kCanBroadcastNode = 0xFFFF¶
The
target_nodevalue meaning “undirected — every node on the bus”.Deliberately outside the 13-bit node range (
> kNodeMax), so no real node id can alias it. Any other value makes the group DIRECTED: a node whose own id differs consumes the group’s data slices without reassembling or delivering them (ADR-0044 transparent per-peer forwarding on a broadcast medium).
-
constexpr std::uint32_t tr::net::can::kEndpointMax = (1u << kEndpointBits) - 1u¶
Largest legal endpoint slot (
2^kEndpointBits - 1).
-
inline bool tr::net::can::encode_advertise_header(std::array<std::byte, kAdvertiseHeaderSize> &out, const advertise_t &a, std::string_view path) noexcept¶
Serialize ONLY the fixed-size header (kAdvertiseHeaderSize bytes) of an advertise_t into
out— on the STACK, nothrow.This is the ONE advertise field-encoding implementation (the
ws.hppencode_frame_header/encode_framesplit, applied to CAN): encode_advertise appends the path after it, andtransport_can::emit_advertisewalks this stack header and then the path’s bytes in place, so slicing an advertise into 8-byte CAN windows allocates NOTHING — it never needed a contiguous buffer (#848). That matters becauseemit_advertiseruns on every CAN send, so the oldstd::vectorwas a per-sendabort()risk under-fno-exceptions.The path is a SEPARATE parameter because the header is a function of the path’s LENGTH, not of who owns its bytes:
transport_canpasses its owncfg_.pathand never copies a path into the advertise_t it emits, which is what keeps the whole emission free of allocation. advertise_t::path onais therefore NOT read here — encode_advertise is the caller that passes it.It also enforces the bound kAdvertiseMaxPathLen only documented before: an over-long path used to be cast to
std::uint16_tunchecked, encoding a frame every decoder rejects (or, past 65535, one whose length field silently truncates) — a permanent, silent wedge.- Parameters:
out – The 18-byte header buffer to fill.
a – The advertise whose header fields to encode (its own
pathis ignored).path – The path this advertise announces — only its length reaches the header.
- Return values:
false –
pathexceeds kAdvertiseMaxPathLen —outis untouched and NOTHING may be emitted for this advertise.
-
inline std::vector<std::byte> tr::net::can::encode_advertise(const advertise_t &a)¶
Serialize an advertise_t frame to its on-wire bytes.
The header comes from encode_advertise_header (the one field-encoding locus); this form appends the path and exists for callers that want the whole frame contiguous. The CAN transport does not use it — it slices the header and the path separately, allocating nothing.
- Parameters:
a – The advertise to encode (its advertise_t::path may be empty).
- Return values:
{} –
a'spath exceeds kAdvertiseMaxPathLen — the advertise is unencodable.- Returns:
The fully serialized frame bytes (kAdvertiseHeaderSize + path length).
-
inline std::optional<std::pair<advertise_t, std::size_t>> tr::net::can::decode_advertise(std::span<const std::byte> buf)¶
Decode exactly one advertise_t frame from the front of
buf.Rejects a wrong magic, an unknown format version, or a non-zero reserved byte (all
std::nullopt). The length check is overflow-safe (a boguspath_lencannot drive an out-of-bounds read).- Parameters:
buf – A byte stream that may hold a partial or whole advertise frame, possibly followed by more bytes.
- Returns:
std::nulloptifbufdoes not yet hold a complete, valid frame (need more bytes, or malformed); otherwise the decoded advertise paired with the number of bytes consumed from the front ofbuf.
-
inline bool tr::net::can::advertise_prefix_plausible(std::span<const std::byte> buf)¶
Could the front of
bufbe a (possibly still incomplete) advertise?Progressive prefix validation for the control-stream decoder’s resynchronization: checks exactly the bytes that have arrived so far — magic, format version, reserved-MBZ, and the kAdvertiseMaxPathLen bound — and never rejects a prefix that more bytes could still complete.
A
trueon an incomplete buffer means “wait for more bytes”; afalsemeans the stream cannot be decoded from this offset (a mid-stream join or a lost control frame left a fragment) and the consumer should drop bytes until the next plausible boundary. decode_advertise stays the single authority for complete frames.- Parameters:
buf – The front of an advertise control byte stream (any length).
- Returns:
Whether an advertise could still begin at offset 0 of
buf.
The L1 splitter¶
-
enum class tr::view::can_frame_mode_t : std::uint8_t¶
Whether a CAN data field is classic (≤8 bytes) or CAN-FD (≤64 bytes).
Values:
-
enumerator CLASSIC¶
Classic CAN 2.0: data field is 0–8 bytes.
-
enumerator FD¶
CAN-FD: data field is 0–64 bytes (8/12/16/20/24/32/48/64 DLC).
-
enumerator CLASSIC¶
-
constexpr std::size_t tr::view::can_frame_count(const view_t &payload, can_frame_mode_t mode) noexcept¶
Number of CAN data-field windows
payloadoccupies inmode.The ceiling division that defines the framing: a payload of
nbytes takesceil(n / can_max_data(mode))frames. An empty payload yields zero frames.- Parameters:
payload – The contiguous source window to frame.
mode – Classic (≤8) or CAN-FD (≤64) framing.
- Returns:
The frame count; pair with can_frame_at to walk the windows.
-
inline view_t tr::view::can_frame_at(const view_t &payload, can_frame_mode_t mode, std::size_t i)¶
The
i-thCAN data-field window ofpayloadinmode(zero-copy).Each window is a view_t::subview over the source segment — never a memcpy. Window
isits ati * can_max_data(mode)and runs to the mode’s data-field limit, except the tail window, which holds the remainder.The framing used to accumulate its windows in a
std::vector<view_t>with a THROWINGpush_back, on a count that scales with a payload size the sending peer chooses; under-fno-exceptionsthat isabort(), not a dropped frame. Bounding that growth from an injected tr::mem::block_source_t — the answertr::net::iov_table_tuses for the socket gather tables — was not available here:block_array_trequires a trivially copyable, trivially destructible element and view_t carries an intrusive refcount. Storing a derivable table was the wrong half of the problem anyway: with nothing stored there is no allocation to bound and no exhaustion path to signal. #1110 deleted the table but kept aview_can_frames_tvalue around it; #932 removed that too, because a class holding(payload, mode)plus a memo of a one-line division is state the caller already has.- The windows are DERIVED, never stored (#1110, #932)
Every window is a pure function of the payload length, the mode and the index, so there is no table: this is O(1), allocation-free, and cannot fail.
- Parameters:
payload – The contiguous source window being framed.
mode – Classic (≤8) or CAN-FD (≤64) framing.
i – Frame index,
0 .. can_frame_count(payload, mode) - 1.
- Returns:
A subview of
payload; the tail window holds the remainder.
-
constexpr std::size_t tr::view::can_max_data(can_frame_mode_t mode) noexcept¶
The maximum data-field length carried by one frame in
mode.
-
constexpr std::size_t tr::view::can_fd_dlc_round_up(std::size_t len) noexcept¶
Round
lenup to the next valid CAN-FD data-length-code (DLC) size.CAN-FD frames may only be 0–8, 12, 16, 20, 24, 32, 48, or 64 bytes, so a frame of an in-between length is padded up to the next legal size on the wire. This pure helper exposes that lattice; the actual padding is the SocketCAN binding’s (deferred-increment) job, so can_frame_at windows stay the exact logical chunk lengths (zero-copy), not padded.
- Parameters:
len – The desired logical data length (
0..kCanFdMaxData).- Returns:
The smallest valid CAN-FD DLC size
>= len(clamped to kCanFdMaxData).
-
constexpr std::size_t tr::view::kCanClassicMaxData = 8¶
Maximum CAN 2.0 (classic) data-field length, in bytes.
-
constexpr std::size_t tr::view::kCanFdMaxData = 64¶
Maximum CAN-FD data-field length, in bytes.
Reassembly¶
-
class can_reassembly_t¶
Reassembles multi-frame CAN payloads from out-of-order slices.
A slice (one CAN data field, as a tr::view::view_t) is added under its group key and index; slices may arrive in any order. Totality is opt-in (set_expected_count, the advertise manifest’s slice count): with it set, a dropped interior slice is detectable (has_interior_gap) and the group is is_complete only when every index
0..count-1is present; a dropped trailing slice is undetectable without it (ADR-0011 totality-opt-in). assemble chains the slices, in index order, into a tr::view::rope_t with zero copies.Structure and slices are drawn from the injected memory resource; when
max_groupsis non-zero and a new group would exceed it, the oldest group is evicted (its buffered slices freed) and dropped_groups is incremented — a bounded drop rather than unbounded growth. Independently of that count bound, sweep_stale reclaims groups that stopped making progress, which is the only thing that frees a group a lost slice left permanently incomplete.Public Functions
-
inline explicit can_reassembly_t(std::pmr::memory_resource *mr = std::pmr::new_delete_resource(), std::size_t max_groups = 0)¶
Construct over
mr, bounding the live group count atmax_groups.- Parameters:
mr – Where the group/slice structure is allocated (default: the process heap).
max_groups – Live-group ceiling;
0means unbounded (the default, and the pre-rehome behavior).
-
inline void add_slice(const reassembly_key_t &key, std::uint32_t index, tr::view::view_t slice)¶
Add (or replace) slice
indexof groupkey.- Parameters:
key – The
(origin, ts)group identity.index – The zero-based slice position.
slice – The slice’s bytes (one CAN data field), borrowed zero-copy.
-
inline void set_expected_count(const reassembly_key_t &key, std::uint32_t count)¶
Declare the expected slice count of group
key(totality opt-in).- Parameters:
key – The group identity.
count – The number of slices the complete group contains.
-
inline bool contains(const reassembly_key_t &key) const¶
True when group
keyis being tracked (has a slice or an expected count).
-
inline std::size_t slice_count(const reassembly_key_t &key) const¶
Number of slices currently buffered for group
key(0 if unknown).
-
inline bool has_interior_gap(const reassembly_key_t &key) const¶
True when an interior slice is missing (a hole below the highest index).
Detects a dropped interior slice even before the count is known; a missing trailing slice is not an interior gap (ADR-0011).
-
inline bool is_complete(const reassembly_key_t &key) const¶
True when
keyhas its expected count set and every index is present.Requires set_expected_count (totality opt-in); without it, completeness is undecidable (a trailing drop is invisible) and this returns false.
-
inline std::optional<tr::view::rope_t> assemble(const reassembly_key_t &key) const¶
Chain the complete group’s slices, in index order, into one rope.
- Parameters:
key – The group identity.
- Returns:
The reassembled tr::view::rope_t, or
std::nulloptunless the group is_complete (totality must be satisfied first).
-
inline void erase(const reassembly_key_t &key)¶
Drop all buffered state for group
key(after assembly or timeout).
-
inline bool discard(const reassembly_key_t &key)¶
Abandon group
keyas one that will NEVER complete — erase it AND count it.The counted twin of erase.
eraseis the post-delivery release: the group’s bytes already reached the receiver, so nothing was lost and nothing is counted. This is the caller-side abandon — the ingress path could not own a slice’s bytes (allocation refusal), so the group is dead and its buffered slices are reclaimed BEFORE delivery. That is exactly what dropped_groups counts, whatever forced it: amax_groupseviction, a sweep_stale age-out, or this.Silence is the alternative this exists to remove: without it a caller either fabricates a placeholder slice (delivering a byte-wrong short frame as valid) or calls erase and loses a whole group with no counter moving (#911).
- Return values:
false – Nothing was tracked under
key— no group, so no drop to count.
-
inline void set_now(std::uint64_t now) noexcept¶
Set the monotonic stamp that subsequent touches mark a group with.
The buffer holds no clock — the caller feeds one (the CAN binding stamps it once per inbound frame with
steady_clockmilliseconds). The unit is whatever the caller uses, and sweep_stale’s age is in the same unit.
-
inline std::size_t sweep_stale(std::uint64_t max_age)¶
Erase every group untouched for longer than
max_age(the stale sweep).A group is “touched” when a slice is added or its expected count is set, so one that stopped making progress — a lost data slice, or an advertise whose group never materialized — ages out here. Without this, such a group is never is_complete, so erase is never reached and its buffered slices are pinned for the process’s life (#912). Each erased group ticks dropped_groups, exactly as a
max_groupseviction does: one counter for “a group’s buffered slices were reclaimed before delivery”, whatever forced it.Note
Ages against the stamp last given to set_now; a caller that never calls it sweeps nothing (every group reads as age 0).
- Returns:
How many groups were erased.
-
inline std::uint64_t dropped_groups() const noexcept¶
Count of groups reclaimed before delivery — a
max_groupseviction or a sweep_stale age-out (never an OOM).
-
inline explicit can_reassembly_t(std::pmr::memory_resource *mr = std::pmr::new_delete_resource(), std::size_t max_groups = 0)¶
-
struct reassembly_key_t¶
The in-flight identity of one address-shift group:
(origin, ts).The collision-free
(origin_peer_id, ts)identity used by cycle-dedup and slice-grouping (CONTEXT.md Address-shift slicing). Each slice’sindexgives its position within the group.Public Functions
-
auto operator<=>(const reassembly_key_t&) const = default¶
Total ordering, so the key works as a
std::mapkey (value type).
-
bool operator==(const reassembly_key_t&) const = default¶
Field-wise equality (value type).
-
auto operator<=>(const reassembly_key_t&) const = default¶
The bus binding¶
-
struct can_frame_data_t¶
One raw CAN frame at the
can_link_tseam — id + data field, no semantics.A mode-agnostic carrier for both a classic CAN 2.0B frame (
fd == false,len <= 8) and a CAN-FD frame (fd == true,lena valid DLC size up to 64). The transport plane fills this in; the link lowers it to the kernelstruct can_frame/struct canfd_frame(or, in tests, an in-memory queue).Public Functions
-
inline std::span<const std::byte> bytes() const noexcept¶
The live data-field bytes as a read-only span.
Public Members
-
std::uint32_t id = 0¶
The 29-bit extended CAN identifier.
-
bool fd = false¶
True ⇒ a CAN-FD frame; false ⇒ classic CAN 2.0.
-
std::uint8_t len = 0¶
Data-field length on the wire (post-DLC-pad for FD).
-
std::array<std::byte, tr::view::kCanFdMaxData> data = {}¶
The data field; only the first len bytes are live.
-
inline std::span<const std::byte> bytes() const noexcept¶
-
class can_link_t¶
The raw-frame seam between transport_can and a physical CAN bus.
Abstracting the socket here is what makes transport_can testable without the kernel
vcanmodule: production uses socketcan_link_t, tests use an in-memory paired link. A link is single-owner (held by one transport) and delivers inbound frames through the registered rx_fn_t, which may fire on an internal receive thread.Two-phase lifecycle (#1186). Construction only OPENS the link — it 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 the link reads a frame with no sink to hand it to. Before this the receive thread was spawned by the constructor and the ordering requirement lived only in prose, which no well-behaved caller could satisfy; the two-phase shape makes it compile-checked for the implementer and explicit for the owner. 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 own rule, not each link’s. Every port of a physical bus gates ingress on can_rx_admissible and egress on can_tx_admissible, so a bus-visible divergence between two ports is a compile-unit-local bug rather than a design choice (#931). 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 no input to judge, and one of them injects raw fragments deliberately.
Subclassed by tr::net::socketcan_link_t
Public Types
-
using rx_fn_t = std::function<void(const can_frame_data_t&)>¶
Callback invoked once per inbound raw CAN frame (may run off-thread).
Public Functions
-
virtual void write_raw(const can_frame_data_t &frame) = 0¶
Emit one raw CAN frame onto the bus.
frameis only BORROWED for the duration of the call. An implementation whose driver transmits asynchronously (queues the frame pointer and formats the buffer later, possibly from a tx-done ISR — e.g. ESP-IDF’sesp_driver_twaibehind the component’stwai_link_t) must copy the frame into storage the LINK owns until the driver signals completion (#383;can_tx_pool.hppis that storage). A synchronous link (socketcan_link_t: the kernel copies inside the write(2) call) may useframedirectly.
-
virtual void on_receive(rx_fn_t rx) = 0¶
Register the sink for inbound raw frames; set before start.
-
virtual void start() = 0¶
Begin reading from the bus — the second phase of the lifecycle (#1186).
Spawns whatever machinery delivers inbound frames (a receive thread, a dispatch task) and is the FIRST point at which rx_fn_t can fire. Call it once, after on_receive; a second call is a no-op, and a link that failed to open stays silent rather than reporting an error — this seam has no error channel, and the open-time predicate (
ok()on the ports that have one) already answers whether the link came up. Frames that arrived between open and this call are not lost: the kernel socket buffer / driver queue holds them until the first read.
-
using rx_fn_t = std::function<void(const can_frame_data_t&)>¶
-
constexpr std::uint8_t tr::net::can_max_len(bool fd) noexcept¶
The largest data field a frame of this mode may declare, as a can_frame_data_t::len.
The widths themselves are the L1 framing layer’s (tr::view::can_max_data — 8 classic, 64 FD, facts of the wire rather than chosen bounds). This is only the seam’s adapter to them: the carrier spells its mode as a
booland its length as astd::uint8_t, so the bound arrives in the same width as the field it bounds and no second copy of the numbers lives here.
-
constexpr bool tr::net::can_rx_admissible(bool extended, bool remote, bool error) noexcept¶
Is an inbound raw frame admissible at the can_link_t seam?
The ONE ingress admission rule, so the platform links cannot drift on which frames they let through (#931). Each link decodes the three flags from its own driver’s representation —
socketcan_link_tfrom theCAN_EFF_FLAG/CAN_RTR_FLAG/CAN_ERR_FLAGbits ofcan_id, the ESP-IDF component’stwai_link_tfromtwai_frame_t::header.ide/.rtr— and the verdict is decided here, once.The binding is header-elided: the 29-bit extended identifier IS the path (ADR-0022), so a standard 11-bit 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 rather than bus payload — admitting either hands the reassembler a slice whose length is a lie, and the flags that told it apart are stripped by the time the id reaches can_frame_data_t::id.
- Parameters:
extended – The frame uses a 29-bit extended identifier, not 11-bit standard.
remote – The frame is a remote-transmission request (RTR).
error – The frame is a controller error/status frame, not bus traffic.
-
constexpr bool tr::net::can_tx_admissible(const can_frame_data_t &frame) noexcept¶
Is
frameemittable at the can_link_t seam?The egress half of the same shared rule: a declared length must fit the data field the frame’s mode actually has. A link that skipped it would
memcpycan_frame_data_t::len bytes out of a 64-byte carrier into an 8-byte kernelstruct can_frame::data— a stack smash the seam precondition alone was holding back (#931).
-
class socketcan_link_t : public tr::net::can_link_t¶
The production
can_link_t— a real Linux SocketCANPF_CANsocket.Opens a
socket(PF_CAN, SOCK_RAW, CAN_RAW), enables CAN-FD frames (CAN_RAW_FD_FRAMES, best-effort — a classic-only controller still works), binds to the named interface (e.g."vcan0"/"can0"); start then spawns the receive thread that translates each kernel frame into a can_frame_data_t for the registered callback. The implementation is selected by the BUILD SYSTEM, not by macros: Linux compilessrc/socketcan_link.cpp(<linux/can.h>), every other platform compilessrc/socketcan_link_stub.cpp, whose ok is always false — so sanitizer/non-Linux builds stay clean and platform ports (e.g. the ESP-IDF component’stwai_link_t) implement can_link_t in their own TU. The send path isMSG_NOSIGNAL-equivalent (a raw CAN write cannot SIGPIPE) and serialized; the fd is reset under the write lock before close on shutdown.Public Functions
-
explicit socketcan_link_t(const std::string &ifname, std::size_t recv_stack = 0)¶
Open + bind a
CAN_RAWsocket on interfaceifname— no reading yet.The socket is live for TX on return; the receive thread is spawned by start, not here (#1186).
- Parameters:
ifname – The CAN network interface name (e.g.
"vcan0").recv_stack – Receive-thread stack size in bytes, 0 = platform default. Non-zero right-sizes the dispatch thread on an MCU (applied via
pthread_attr_setstacksizeat start; mirrorsnet::posix_endpoint_t::start).
-
~socketcan_link_t() override¶
Stop the receive thread and close the socket.
-
virtual void write_raw(const can_frame_data_t &frame) override¶
Write one frame to the bus (classic or FD per can_frame_data_t::fd).
-
virtual void on_receive(rx_fn_t rx) override¶
Register the inbound-frame sink (invoked on the receive thread).
-
virtual void start() override¶
Spawn the receive thread — call after on_receive (#1186).
A no-op when the socket never opened or the thread is already running. Frames the kernel buffered since the bind are read by the first iteration, so the two-phase split loses nothing.
-
inline bool ok() const noexcept¶
True if the socket opened and bound (false on non-Linux or any error).
-
explicit socketcan_link_t(const std::string &ifname, std::size_t recv_stack = 0)¶
-
struct transport_can_config_t¶
Static identity of a transport_can node on the bus.
Fixes the CAN-ID
version/nodeband this transport transmits in and the framing mode it slices into. path is the libtracer path this node binds in its outboundadvertisemanifests (theid ↔ paththe map establishes).Public Members
-
std::uint8_t version = 0¶
Protocol-version prefix (discovery-layer versioning).
-
std::uint16_t node = 0¶
This node’s id (the CAN-ID
nodeband).
-
tr::view::can_frame_mode_t mode = tr::view::can_frame_mode_t::CLASSIC¶
Classic (≤8B) or CAN-FD (≤64B) framing.
-
std::string path¶
The path advertised for this node’s groups.
-
std::chrono::milliseconds peer_ttl = kCanDefaultPeerTtl¶
Peer liveness window (ADR-0044): a peer silent longer than this expires from the enumeration.
-
std::pmr::memory_resource *reasm_mr = std::pmr::new_delete_resource()¶
Where the RX buffers (reassembly groups/slices and the pending-slice queue) draw their structure. A constrained node injects a bounded resource; the default is the process heap. Must outlive the transport.
-
std::size_t max_groups = 0¶
Live reassembly-group ceiling;
0= unbounded (host-bounded per RFC-0006). Overflow evicts the oldest group and ticks transport_can::dropped_groups.
-
std::size_t max_pending = 0¶
Ceiling on data slices parked awaiting their advertise;
0= unbounded (host-bounded per RFC-0006). Overflow evicts the oldest parked slice and ticks transport_can::dropped_rx.
-
std::chrono::milliseconds rx_ttl = kCanRxTtlFromPeerTtl¶
RX staleness window: a parked slice or an incomplete reassembly group untouched this long is reclaimed, because a lost advertise/data slice would otherwise pin it forever.
0= trackpeer_ttl(kCanRxTtlFromPeerTtl); ifpeer_ttlis itself0the window stays0, which retains only what arrived this instant — the same reading the peer enumeration gives that value, never “disabled”. Unlike the count caps this is ALWAYS live — the age-out is the bound that holds under the shipped default config.
-
mem::mem_backend_t *rx_backend = nullptr¶
The byte seam an inbound data slice is COPIED into before it enters the reassembly buffer (
tr::view::over_bytes’s injected form, #793).nullptr= the process heap, which is what this path used unconditionally before #911. A constrained node injects a bounded backend (mem::pool_t) so ingress exhaustion is a by-value refusal on the RX thread instead of a reach into the global heap; a refusal drops the whole group and ticks transport_can::dropped_rx. Must outlive the transport — the segments it hands out are released by it. Companion to reasm_mr — that one bounds the reassembly STRUCTURE, this one the slice BYTES.
-
std::uint8_t version = 0¶
The two sentinels its liveness fields default to. They are published because the struct’s own
member docs @ref them, and an @ref to a symbol no page publishes is a dead link the docs
gate rejects — the same rule that forbids @ref-ing a private.
-
constexpr std::chrono::milliseconds tr::net::kCanDefaultPeerTtl = {3000}¶
Default liveness window: a peer silent this long leaves the enumeration.
-
constexpr std::chrono::milliseconds tr::net::kCanRxTtlFromPeerTtl = {0}¶
Sentinel for
transport_can_config_t::rx_ttl: trackpeer_ttlinstead.The RX staleness window is not an independent quantity to invent a number for (the no-synthetic-limits rule): a peer that has been silent longer than
peer_ttlis already considered gone, so RX state it would have completed is definitively dead by then. Left at zero,rx_ttlresolves topeer_ttl.
-
constexpr std::size_t tr::net::kCanMaxGroupSlices = static_cast<std::size_t>(can::kEndpointMax) - kCanFirstDataEndpoint + 1u¶
The largest address-shift group this node can place: every data endpoint slot.
Address-shift slicing spreads a group across CONSECUTIVE endpoint slots of one node (
endpoint[base .. base+count-1]), so a group of more slices than the data-endpoint window holds can never be placed at any base — no wrap helps. DERIVED from the CAN-ID field widths (can::kEndpointMax) minus the reserved control slot, never a chosen number: the bound is the wire’s, so wideningkEndpointBitswidens this with it.A group over this bound is refused WHOLE, before its manifest is emitted (#910) — advertising
slice_countslices and then running out of slots mid-loop leaves every receiver holding a reassembly group that can never complete.
The first slot the endpoint allocator may hand out, published under the same rule: it is
the value alloc_base wraps back to, so dropped_stale_binding()’s doc @refs it when it
explains how the receiver observes a lap.
-
constexpr std::uint16_t tr::net::kCanFirstDataEndpoint = 1¶
The first
endpointslot usable for header-elided data groups (control is 0).
-
class transport_can : public tr::net::transport_t, public tr::net::bus_link_t¶
A
transport_tover Linux SocketCAN — header-elided, self-establishing.Wires the increment-1 framing to a live bus. Egress (send): the frame is address-shift-fragmented by tr::view::can_frame_at into CAN data fields, an in-band tr::net::can::advertise_t manifest (carrying the slice count and exact total length) is emitted on the control ID, then the lean id-matched data frames follow — CAN-FD windows DLC-padded up to a legal size. Ingress (the link’s receive thread): advertise frames populate the dynamic identity↔path map; data frames are reassembled by can_reassembly_t keyed by
(node, base-endpoint) + slice-index, trimmed back to the advertised total (undoing FD padding), and delivered byte-exact to the receiver. The map is rebuilt purely from advertise frames, so a rejoining node self-heals with no coordinator (ADR-0030). The base endpoint RECURS — the 12-bit space wraps — so that key is only unambiguous because a fresh advertise retires every binding whose endpoint run it overlaps, and the group each was feeding with it (#909,invalidate_overlapping).Bus capability (ADR-0044). The bus reaches many peers over one wire, so the transport also implements bus_link_t — statelessly, from live traffic:
a last-heard table (one entry per DISTINCT node id ever heard — like the identity↔path map, it grows with the bus population, structurally bounded by the 13-bit node-id space, never per-request/per-frame; memory policy is the host’s) is refreshed by every valid same-version frame another node emits, seeded by the hello advertise (
slice_count == 0) a node sends at join; a peer silent longer thanpeer_ttlexpires from view;enumerate_peers synthesizes the currently-audible peer names —
n<node-id>(decimal, no leading zeros): deterministic and collision-safe within the bus, since the structured CAN ID makes node ids unique per bus;peer_link resolves such a name to a per-peer DIRECTED endpoint whose
sendstamps the group’s advertise withtarget_node, so on the broadcast medium only the addressed peer reassembles and delivers it;set_peer_receiver tags each delivered frame with the SENDER’s peer name (derived from the CAN ID), which the FWD router uses as the hop’s inbound NAME — replies route back per-peer with no per-request state. No peer ever creates a vertex or any other graph state (ADR-0044 §1).
Public Functions
-
transport_can(std::unique_ptr<can_link_t> link, transport_can_config_t config)¶
Bind this transport to raw link
linkwith node identityconfig.Drives the link’s two-phase lifecycle (#1186) on the owner’s behalf: registers the receiver, THEN calls can_link_t::start. A caller that hands its link here must not have started it.
- Parameters:
link – The owned raw-frame link (a socketcan_link_t in production), open but not yet started.
config – This node’s version/node/mode/path identity on the bus.
-
~transport_can() override¶
Detach the receiver and release the link (stopping its receive thread).
-
virtual void send(std::span<const std::byte> frame) override¶
Fragment
frameacross CAN frames and emit it (advertise + data).Empty frames are dropped. Thread-safe: a whole group (its advertise and data frames) is emitted under one lock so concurrent sends never interleave.
- Parameters:
frame – A complete libtracer frame (a ROUTER-wrapped TLV’s bytes).
-
std::optional<can::advertise_t> learned_binding(std::uint32_t base_can_id) const¶
Look up a learned
id ↔ pathbinding by its base CAN ID (test/introspection hook).- Parameters:
base_can_id – The advertised group’s base 29-bit CAN ID.
- Returns:
The learned tr::net::can::advertise_t, or
std::nulloptif unknown.
-
inline std::uint64_t dropped_rx() const noexcept¶
Inbound frames dropped rather than delivered (the tcp/quic/udp
dropped_rx()convention).Ticks once per parked data slice reclaimed because transport_can_config_t::max_pending was reached or because it aged past
rx_ttl— a bounded, counted drop instead of the unbounded park this replaces — and once per inbound slice whose bytes could not be owned (transport_can_config_t::rx_backend refused, #911). It counts SLICES, not groups; a group’s buffered slices reclaimed as a unit are dropped_groups, which the refusal also ticks because the group it belonged to is abandoned rather than completed with a fabricated slice.
-
inline std::uint64_t dropped_presink() const noexcept¶
Reassembled groups dropped because they completed inside the sink-install window — the transport existed (link receiving, RX callback registered) but no receiver sink was installed yet (#1103, ADR-0081 §4).
transport_vertex_t::make_connectionconstructs the link, then registers the connection vertex, and only then wires the receiver viafwd_router_t::add_child. A bus needs no provocation to fill that span: any bystander traffic already on the wire lands in it. CAN is ADR-0081’s drop arm because neither escape exists — the bus has no per-peer flow control to hold bytes in, and withholding the RX callback would starve the liveness bookkeeping it drives (last_heard, the pending/reassembly sweeps); parking the group inside the library is banned outright. So a group that completes while both receiver slots are empty is dropped at the delivery seam and counted HERE — a distinctly named cause, never folded into dropped_rx or dropped_groups and never silent. Counts GROUPS. A deployment that sees it moving is watching the sink-install window, not guessing.
-
inline std::uint64_t dropped_stale_binding() const noexcept¶
Inbound data slices refused because the binding they resolved to predates the producer’s current endpoint-allocator lap — the WELD, counted (#1011).
The retire-on-re-issue rule (#909) fires only on OVERLAP, so a binding whose endpoint run a later advertise merely skipped over survives. Data slices whose own advertise was lost on the bus then resolve first-match to that survivor, fill the indices its lost slices left empty, and complete its stale group: two unrelated payloads welded into one frame, trimmed to the length the STALE manifest promised, and delivered upstream as valid. Silent, and the size the receiver expected.
The lap is what makes it decidable without spending endpoint bits (ADR-0077’s option 1, still declined):
alloc_baseissues strictly ascending bases and wraps to kCanFirstDataEndpoint, so an advertise whose base does not exceed the last one seen from that node is proof the producer’s allocator came round. Every binding of that node then belongs to a PRIOR lap, and a slice resolving to one is refused rather than welded: the group is discarded (ticking dropped_groups, as every other pre-delivery reclamation does) and the slice is counted HERE.Counts SLICES, like dropped_rx — one per refused slice, not one per group. A distinctly named cause, never folded into dropped_rx (which is backpressure and age-out) and never silent: a deployment that sees this moving is watching lost advertises on a lapping bus, not guessing.
-
inline std::uint64_t dropped_tx() const noexcept¶
Outbound frames the caller believed sent that never reached the bus (the twai
tx_dropped()convention, spelled to matchdropped_rx).Ticks when a
sendcannot own its bytes (allocation failure — the backpressure case), when the payload splits into no window at all, when the group needs more consecutive endpoint slots than kCanMaxGroupSlices (refused WHOLE, before any manifest goes out — #910), and when the group’s advertise manifest cannot be encoded.
-
std::uint64_t dropped_groups() const¶
Reassembly groups reclaimed before delivery — a
max_groupseviction, anrx_ttlage-out, or a base-endpoint run being re-bound (#909).The third form is the wraparound one: the 12-bit endpoint space recurs, so a fresh advertise over a run that a stale binding still claims retires that binding and the group it was feeding. That group can never complete (nothing resolves to it again) and its slices must not merge into the fresh group, so it is reclaimed here rather than aged out — one counter for “a group’s
buffered slices were reclaimed before delivery”, whatever forced it.
The accessor the reassembly buffer’s own counter never had: it is private state on the RX thread, so this reads it under the ingress lock.
-
std::size_t pending_slices() const¶
Data slices currently parked awaiting their advertise (introspection).
-
inline virtual transport_drop_stats_t drop_stats() const noexcept override¶
The interface-level snapshot (#932) — what a generic
transport_t*reads.malformed_rxstays zero: this bus has no framing-desync class to report — an ingress slice is either owned and reassembled or shed into dropped_rx — so it reports nothing rather than folding a different meaning into that field.
-
inline virtual bus_link_t *bus() override¶
This link IS a bus — expose the bus_link_t facet.
-
virtual void enumerate_peers(const peer_visitor_t &visit) const override¶
Visit the peers currently audible on the bus, as
n<node-id>names.Synthesized on the fly from the last-heard table — a snapshot of live traffic, never stored graph structure (ADR-0044 §1). Entries older than the configured
peer_ttlare skipped.Note
visitruns under the table lock; it must not re-enter this link.
-
virtual transport_t *peer_link(std::string_view peer) override¶
Resolve
n<node-id>to this bus’s directed endpoint for that peer.- Return values:
nullptr –
peeris not a canonical peer name, or the peer expired.
-
virtual std::string_view peer_name(peer_handle_t peer, std::span<char> scratch) const override¶
Resolve an inbound handle back to its
n<node-id>peer name (#1294).The handle’s index IS the bus node id and the name is a pure function of it, so this is
format_peer_nameand nothing else — no table lookup, no lock, no liveness check (an announce-census peer has no session whose liveness could be asked about, which is exactly why the generation is the constantkAnnouncedPeerGeneration).
-
inline virtual bool delivers_ropes() const override¶
True — the CAN bus reassembles into ropes and delivers them as-is.
Public Static Attributes
-
static constexpr std::uint32_t kAnnouncedPeerGeneration = 1¶
The generation every CAN peer handle carries (#1294).
An announce-census bus learns of a peer from another node’s traffic and has no accept/depart closure at all (RFC-0009 §D.5), so there is no tenancy for a generation to count: the node id alone IS the identity, and it is immune to the slot-reuse confusion a positional kind’s generation exists to catch. It stays a non-zero constant so the handle is always peer_handle_t::valid, per the seam’s no-null-arm rule.
-
template<typename slot_t>
class can_tx_pool_t¶ Fixed-capacity pool of TX frame slots owned by a link until tx-done.
Ownership protocol: try_acquire hands out a free slot the caller fills and submits to the driver; the slot stays IN FLIGHT — the driver may read it at any point, including from ISR context — until the driver’s completion callback hands it back through release. Release is a single compare-exchange on the slot’s flag: no locks, no allocation, no system calls, so it is safe to call from a tx-done ISR — and it validates the pointer it is handed, because that pointer comes from the driver (#932).
Concurrency contract (matches the
can_link_tseam, where writes are serialized under the link’s write lock):try_acquire callers are serialized EXTERNALLY (one acquirer at a time); the internal scan hint is deliberately unsynchronized.
release may run concurrently with try_acquire from any context (thread or ISR). The release/acquire flag pairing orders the completed transmit before the slot’s next reuse.
- Template Parameters:
slot_t – The link-defined slot record (driver frame descriptor + payload bytes). Must be default-constructible; the pool never reads or writes slot contents.
Public Functions
-
inline explicit can_tx_pool_t(std::size_t capacity)¶
Allocate
capacityslots, all initially free.Size it to the driver’s maximum in-flight frame count (for the ESP TWAI node:
tx_queue_depth+ the hardware TX slot), so a successful acquire implies the driver can accept the frame without waiting.- Parameters:
capacity – Number of slots; at least 1 is enforced.
-
inline slot_t *try_acquire() noexcept¶
Claim a free slot, or
nullptrwhen every slot is in flight.Never blocks — exhaustion is the caller’s FULL-policy decision point (bounded backpressure, counted drop, …). Callers are serialized externally per the class contract.
-
inline bool release(slot_t *slot) noexcept¶
Return
slotto the pool once the driver is done with it.ISR-safe: one compare-exchange plus a relaxed counter update — no locks, no allocation.
slotshould be a pointer previously handed out by try_acquire on this pool and not yet released — but the caller is typically a DRIVER completion callback (the TWAI tx-done ISR) handing back a driver-supplied pointer, so the pool VALIDATES it rather than trusting it (#932): a pointer outside this pool’s slot array, and a double or foreign release of a slot that is not in flight, are both refused instead of corrupting thein_flight_/count_bookkeeping (out-of-bounds store, counter underflow) from ISR context.- Returns:
true iff
slotwas an in-flight slot of this pool and was freed.
-
inline std::size_t capacity() const noexcept¶
The fixed slot count chosen at construction.
-
inline std::size_t in_flight() const noexcept¶
Slots currently in flight (approximate under concurrent release).
See: reference §CAN transport (the narrative),
transport (the seam transport_can implements),
views (the subview machinery the splitter uses),
fwd-router.