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 0 is 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).

Public Members

std::uint8_t version = 0

Protocol-version prefix (0..kVersionMax).

std::uint16_t node = 0

Originating node id (0..kNodeMax).

std::uint16_t endpoint = 0

Per-node endpoint slot (0..kEndpointMax).

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::nullopt if id does 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 index of 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 slice index simply shifts the endpoint sub-field. The id stays in the same version/node band, so the whole group keeps one arbitration priority class.

Parameters:
Returns:

The packed 29-bit ID for the slice, or std::nullopt if base.endpoint + index would overflow the endpoint field.

struct advertise_t

A decoded in-band advertise frame — 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 / presenceslice_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.

  • directedtarget_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 stripped dst segment 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 explicit target_node field (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

flags bit: 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 single id path value binding (CONTEXT.md Advertise + id-match).

constexpr std::uint16_t tr::net::can::kAdvertiseMaxPathLen = 1024

Largest path_len a 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_node value 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.hpp encode_frame_header / encode_frame split, applied to CAN): encode_advertise appends the path after it, and transport_can::emit_advertise walks 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 because emit_advertise runs on every CAN send, so the old std::vector was a per-send abort() 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_can passes its own cfg_.path and never copies a path into the advertise_t it emits, which is what keeps the whole emission free of allocation. advertise_t::path on a is 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_t unchecked, 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 path is ignored).

  • path – The path this advertise announces — only its length reaches the header.

Return values:

falsepath exceeds kAdvertiseMaxPathLenout is 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's path 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 bogus path_len cannot 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::nullopt if buf does 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 of buf.

inline bool tr::net::can::advertise_prefix_plausible(std::span<const std::byte> buf)

Could the front of buf be 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 true on an incomplete buffer means “wait for more bytes”; a false means 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).

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 payload occupies in mode.

The ceiling division that defines the framing: a payload of n bytes takes ceil(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-th CAN data-field window of payload in mode (zero-copy).

Each window is a view_t::subview over the source segment — never a memcpy. Window i sits at i * 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 THROWING push_back, on a count that scales with a payload size the sending peer chooses; under -fno-exceptions that is abort(), not a dropped frame. Bounding that growth from an injected tr::mem::block_source_t — the answer tr::net::iov_table_t uses for the socket gather tables — was not available here: block_array_t requires 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 a view_can_frames_t value 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 len up 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-1 is 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_groups is 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 at max_groups.

Parameters:
  • mr – Where the group/slice structure is allocated (default: the process heap).

  • max_groups – Live-group ceiling; 0 means 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 index of group key.

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 key is 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 key has 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::nullopt unless 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 key as one that will NEVER complete — erase it AND count it.

The counted twin of erase. erase is 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: a max_groups eviction, 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_clock milliseconds). 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_groups eviction 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_groups eviction or a sweep_stale age-out (never an OOM).

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’s index gives its position within the group.

Public Functions

auto operator<=>(const reassembly_key_t&) const = default

Total ordering, so the key works as a std::map key (value type).

bool operator==(const reassembly_key_t&) const = default

Field-wise equality (value type).

Public Members

can_origin_id_t origin = {}

The originating node id (16 bytes).

std::uint64_t ts = 0

The group’s per-producer monotonic timestamp.

The bus binding

struct can_frame_data_t

One raw CAN frame at the can_link_t seam — 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, len a valid DLC size up to 64). The transport plane fills this in; the link lowers it to the kernel struct 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.

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 vcan module: 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 startwrite_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

Callback invoked once per inbound raw CAN frame (may run off-thread).

Public Functions

Emit one raw CAN frame onto the bus.

frame is 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’s esp_driver_twai behind the component’s twai_link_t) must copy the frame into storage the LINK owns until the driver signals completion (#383; can_tx_pool.hpp is that storage). A synchronous link (socketcan_link_t: the kernel copies inside the write(2) call) may use frame directly.

Register the sink for inbound raw frames; set before start.

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.

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 bool and its length as a std::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_t from the CAN_EFF_FLAG / CAN_RTR_FLAG / CAN_ERR_FLAG bits of can_id, the ESP-IDF component’s twai_link_t from twai_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 frame emittable 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 memcpy can_frame_data_t::len bytes out of a 64-byte carrier into an 8-byte kernel struct can_frame::data — a stack smash the seam precondition alone was holding back (#931).

The production can_link_t — a real Linux SocketCAN PF_CAN socket.

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 compiles src/socketcan_link.cpp (<linux/can.h>), every other platform compiles src/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’s twai_link_t) implement can_link_t in their own TU. The send path is MSG_NOSIGNAL-equivalent (a raw CAN write cannot SIGPIPE) and serialized; the fd is reset under the write lock before close on shutdown.

Public Functions

Open + bind a CAN_RAW socket on interface ifname — 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_setstacksize at start; mirrors net::posix_endpoint_t::start).

Stop the receive thread and close the socket.

Write one frame to the bus (classic or FD per can_frame_data_t::fd).

Register the inbound-frame sink (invoked on the receive thread).

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.

True if the socket opened and bound (false on non-Linux or any error).

struct transport_can_config_t

Static identity of a transport_can node on the bus.

Fixes the CAN-ID version/node band this transport transmits in and the framing mode it slices into. path is the libtracer path this node binds in its outbound advertise manifests (the id path the 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 node band).

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 = track peer_ttl (kCanRxTtlFromPeerTtl); if peer_ttl is itself 0 the window stays 0, 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.

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: track peer_ttl instead.

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_ttl is already considered gone, so RX state it would have completed is definitively dead by then. Left at zero, rx_ttl resolves to peer_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 widening kEndpointBits widens this with it.

A group over this bound is refused WHOLE, before its manifest is emitted (#910) — advertising slice_count slices 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 endpoint slot 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_t over 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 than peer_ttl expires 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 send stamps the group’s advertise with target_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

Bind this transport to raw link link with node identity config.

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 frame across 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 path binding 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::nullopt if 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_connection constructs the link, then registers the connection vertex, and only then wires the receiver via fwd_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_base issues 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 match dropped_rx).

Ticks when a send cannot 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_groups eviction, an rx_ttl age-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_rx stays 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_ttl are skipped.

Note

visit runs 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:

nullptrpeer is 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_name and 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 constant kAnnouncedPeerGeneration).

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_t seam, 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 capacity slots, 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 nullptr when 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 slot to the pool once the driver is done with it.

ISR-safe: one compare-exchange plus a relaxed counter update — no locks, no allocation. slot should 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 the in_flight_/count_ bookkeeping (out-of-bounds store, counter underflow) from ISR context.

Returns:

true iff slot was 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.