views — zero-copy windows (L1)¶
In one paragraph
A view_t is a (segment, offset, length) window onto real bytes; copying it is
a refcount clone, not a byte copy. A rope_t is a chain of views, so one logical
message can span several buffers (a static header + a live DMA payload) without
copying. decode(view_t) realizes L1’s load-bearing claim — a TLV is a cast from
a view — by running the M1 decoder over a view’s bytes in place.
What it does¶
L1 sits between real memory (L0) and TLV bytes (L2). Its types — view_t, rope_t,
and segment_ptr_t — live in tr::view. It owns the ownership
semantics, not the bytes. A single-link view_t is the hot path and allocates
nothing; a multi-link rope_t models scatter-gather. rope_t::to_iovec() hands the
chain to writev/sendmsg-style egress with zero copies; rope_t::flatten()
materializes it into one contiguous segment only when a flat-buffer consumer
demands it (the single transport-boundary copy). Assembling a multi-buffer message is
chaining views into a rope_t, never a memcpy — a contiguous copy happens only
when flatten() runs at a substrate boundary that cannot scatter-gather.
decode(v) is just decode(v.bytes()) — the decoded tlv_t’s payload spans
point into the view’s segment, and the view’s segment_ptr_t keeps them alive. No
decode-into-a-struct step: the wire bytes are the in-memory value.
Ownership is an intrusive refcount on the segment, not on the view: cloning a
segment_ptr_t increments relaxed, dropping one decrements acq_rel and fires
the backend’s destroy when the pre-decrement value was 1 (tr::view::detail::ref_count_t,
core/include/libtracer/segment.hpp:52-54; the clone and release sites are
segment_ptr_t’s copy constructor and reset, segment.hpp:126 and :139). Relaxed
on the increment is sound because a clone is always made from a reference the caller
already holds; the acq_rel decrement is what orders the last writer’s stores before the
destructor reads them. A LIBTRACER_NO_ATOMIC build substitutes a plain counter with
the same call shape (segment.hpp:44-47).
Interface¶
namespace tr::view {
struct view_t { // view.hpp
segment_ptr_t owner; std::size_t offset, length;
static view_t over(segment_ptr_t) noexcept; // whole segment :41
std::span<const std::byte> bytes() const noexcept; // :53
view_t subview(std::size_t off, std::size_t len) const; // shares owner :74
};
/** Own a copy of borrowed bytes as a view_t; nullopt == allocation failure. */
std::optional<view_t> over_bytes(std::span<const std::byte>) noexcept; // mem_heap.hpp:340
std::optional<view_t> over_bytes(std::span<const std::byte>, mem::mem_backend_t&) noexcept; // :375
class rope_t { // rope.hpp — ordered chain of views
rope_t(view_t); // a view is a 1-link rope :53
void append(view_t); rope_t& concat(const rope_t&); // :56, :73
std::size_t link_count() const noexcept; // :119
std::size_t total_length() const noexcept; // :154
template <class Fn> void walk(Fn&&) const; // :204
const view_t& only() const noexcept; // SINGLE-LINK ONLY :134
view_t materialize(mem_backend_t& = mem::heap_backend()) const; // 0 or 1 copy :148
std::vector<std::span<const std::byte>> to_iovec() const; // zero-copy egress :213
bool try_to_iovec(std::vector<std::span<const std::byte>>&) const noexcept; // :230
view_t flatten(mem_backend_t& = mem::heap_backend()) const; // one-copy :247
};
} // namespace tr::view
std::expected<tlv_t, err_t> tr::wire::decode(const view_t&, block_source_t& = heap_source());
// the L1 → L2 cast frame.hpp:221
Rope = one message, many buffers¶
flowchart LR
H["view A · header<br/>static segment"] --> P["view B · payload<br/>DMA segment"] --> T["view C · tail<br/>pool segment"]
H -.-> S1[(seg 1)]
P -.-> S2[(seg 2)]
T -.-> S3[(seg 3)]
R["rope_t.to_iovec() → writev()"]:::e
H --- R
classDef e fill:#dbeafe,stroke:#1e40af;
A rope holds its first two links in small-buffer storage (kInline = 2,
core/include/libtracer/rope.hpp:425); the third link spills the whole chain to the
heap, which is the chain’s only allocation (rope_t::append, rope.hpp:76-91).
Owning a copy of borrowed bytes¶
Bytes handed up by a transport are borrowed: they live in a connection buffer that is
reused as soon as the callback returns. Keeping them means owning a copy, and the
canonical way to take one is tr::view::over_bytes
(core/include/libtracer/mem_heap.hpp:340) — one call in place of the
heap_alloc + memcpy + view_t::over triplet. A second overload (:377) takes the
backend to draw from, which is what a peer-driven ownership copy uses so the copy lands in
the node’s injected seam rather than the global heap.
tr::graph::result_t<void> store(tr::graph::graph_t& g, const tr::graph::path_t& path,
std::span<const std::byte> borrowed) {
std::optional<tr::view::view_t> owned = tr::view::over_bytes(borrowed);
if (!owned) return std::unexpected(tr::graph::status_t::BACKPRESSURE); // allocation failed
return g.write(path, tr::view::rope_t{*owned});
}
The std::optional return exists to separate two outcomes that a bare view_t conflates:
Result |
Meaning |
Caller’s move |
|---|---|---|
|
the segment allocation failed |
map to |
engaged, empty |
|
proceed; the value is the empty value |
engaged, non-empty |
an owned copy of |
proceed |
The same call — in its seam-taking overload, drawing from the transport’s injected
backend rather than the global heap — is what the RFC 6455 fragment assembler uses to turn
each borrowed fragment into an owning link before chaining it (ws_assembler_t::on_data,
core/src/transport_ws.cpp:100), so the copy out of the connection buffer is the one
legitimate substrate-boundary copy and the chaining that follows is pointer-linking.
Consequences¶
A TLV is a view — no parse-into-struct; the decoder returns borrowed spans, so reading a field is a pointer load.
Scatter-gather without copies — compose a message from separate buffers and emit it with one
writev; flatten only when a transport truly needs contiguity.Slicing is free —
subview/concatbuild new view structs that bump the segment refcount; no bytes move.Contiguity is a consumer’s explicit choice, not a default — a value arrives as a rope, and the consumer states whether it can accept a chain (
walk,to_iovec) or needs one buffer (materialize,flatten). The rejected alternative was flattening on the way out of the graph, which pays the copy for every consumer including the ones that scatter (ADR-0053, Lazy rope-backed decode-view and partial-path routing).over_byteslives intr::view, nottr::mem— it hands back an owning handle, and an owning handle is an L1 concept (ADR-0016, Substrate zero-copy, layer namespaces, no templates through the seam).
Pitfalls¶
only() is valid only on a single-link rope. The precondition is link_count() == 1
and it is debug-asserted (rope_t::only, core/include/libtracer/rope.hpp:198-204).
With NDEBUG the assert is compiled out and only() returns the first link, so a
multi-link value is read as if the first buffer were the whole message — a silent
truncation, not a diagnostic. This is invisible on a purely local graph, where every
value is one segment, and appears the moment a real transport is attached: every
transport whose transport_t::delivers_ropes() returns true
(core/include/libtracer/transport.hpp:621; TCP, UDP, WS, QUIC, WebTransport and CAN
all override it) can hand up a chain. A CAN reassembly group chains one link per slice
(can_reassembly_t::assemble, core/include/libtracer/can_reassembly.hpp:191-199), and
a fragmented WebSocket message chains one link per fragment
(ws_assembler_t::on_data, core/src/transport_ws.cpp:86-111). A consumer that cannot
promise contiguity calls materialize() (rope.hpp:219) instead — zero copy when the
rope happens to be single-link, one flatten copy otherwise. only() is the right call
only where the surrounding code has already established that the rope is one link.
to_iovec() allocates and can throw. It reserves a span table per call, which
under -fno-exceptions turns an out-of-memory into abort(). Egress paths that build
this table per send use try_to_iovec(out), which probes the exact allocation first and
returns false instead, leaving out empty (rope.hpp:301-332). ⚠️ The probe is not a hard
nothrow guarantee: tr::detail::try_reserve frees its probe block and then runs the
throwing reserve, so on a multi-threaded node a racing allocation between the two can still
abort (#850); the header qualifies its
own comment with “single-threaded” for exactly this reason.
Treating an empty over_bytes result as an empty value loses backpressure.
std::nullopt and an engaged-but-empty view are different answers; collapsing them
reports a failed allocation as a successful write of nothing.
API reference¶
-
struct view_t¶
A single contiguous window into one segment_t.
Copyable; copy == clone (bumps the segment refcount). The common case is one link; ropes (
rope.hpp) chain several.Note
Invariant:
offset + length <= owner->bytes.size().Public Functions
-
inline bool empty() const noexcept¶
True when the window is empty.
-
inline std::span<const std::byte> bytes() const noexcept¶
The window’s bytes as a read-only span (empty if unowned).
Warning
For a is_device window the span addresses non-CPU memory — do not dereference it on the CPU (docs/adr/0024).
-
inline bool is_host() const noexcept¶
True when this window’s bytes are CPU-addressable (HOST). Unowned ⇒ host.
-
inline bool is_device() const noexcept¶
True when this window’s bytes are non-CPU (DEVICE, e.g. GPU memory).
Public Members
-
segment_ptr_t owner¶
The segment whose bytes this view borrows.
-
std::size_t offset = 0¶
Byte offset of the window within the segment.
-
std::size_t length = 0¶
Window length in bytes.
Public Static Functions
-
static inline view_t over(segment_ptr_t seg) noexcept¶
A view covering the whole of
seg.
-
inline bool empty() const noexcept¶
-
enum class tr::view::flatten_err_t : std::uint8_t¶
Why the single contiguous copy could not be taken (#917).
The two refusals are DIFFERENT verdicts and a caller must not conflate them: flatten_err_t::NO_MEMORY is transient backpressure (the same rope may flatten once the allocator recovers), while flatten_err_t::NOT_HOST is a property of the rope itself (a DEVICE link the CPU must not dereference, docs/adr/0024) — no retry fixes it and the payload must go via its device path. Before rope_t::try_flatten both collapsed into an empty view_t, indistinguishable from each other AND from a legitimately empty rope, so a router reading that empty as “malformed frame” reported a local OOM as a PERMANENT protocol error against the peer.
Values:
-
enumerator NOT_HOST¶
The rope has a DEVICE link — not CPU-flattenable, ever.
-
enumerator NO_MEMORY¶
The backend refused the segment — transient backpressure.
-
enumerator NOT_HOST¶
-
class rope_t¶
An ordered chain of view_t links — one logical byte sequence spread across segments, assembled by chaining and never by copying.
The rope is the transport-agnostic scatter-gather representation: each transport lowers it to its native DMA (
iovec/sendmsg, CAN descriptors, RDMA verbs) via to_iovec. The single contiguous copy is flatten, taken only at a substrate boundary that cannot scatter-gather.Public Functions
-
inline rope_t &concat(const rope_t &other)¶
Chain
other'slinks onto this rope (no copy).Self-concat safe by construction (
r.concat(r)), and the safety is charged only to the case that needs it. Source and destination storage can overlap in exactly one way —&other == this; two distinctrope_ts own disjoint chains — so the aliasing case gets its own arm and the cross-rope arm pays for none of its guards.On the ALIASING arm, append mutates the very storage
other.links()spans: the inline→heap spill blanks every inline slot and zeroesinline_n_mid-walk (so a naive range-for yielded[a,b,a,{}]instead of[a,b,a,b]), and a heappush_backcan reallocate the vector the walk points into (a dangling span). Two independent guards close that: try_reserve pins the final link count up front, so none of the appends spills or reallocates; and the walk indexes the source afresh each step, so linkiis re-read from wherever the chain now lives even if the reservation soft-failed.appendonly ever adds a link at the end — it never reorders or drops one, and the spill migrates linkito heap indexi— so indexinames the same link for the whole walk.The CROSS-ROPE arm walks the source span once and appends:
other’s storage is disjoint from ours, so nothing this loop does can invalidate it, and neither guard buys anything. Charging them there cost path-target delivery +3.5% / +10.1% (inproc-target-*, #1022) for the hot 1–2-link clone. A caller that wants one sized growth instead of the geometricpush_backladder for a long cross-rope join calls try_reserve itself with the count it already holds — which is what the delivery clone, the composed-read reply builder and the folded child listing do.
-
inline bool try_reserve(std::size_t links) noexcept¶
Nothrow-reserve room for
linksmore append / concat links — the soft-fail growth the composed-reply builder needs.The chain’s spill to
heap_is astd::vectorgrowth that throwsstd::bad_allocon OOM, which under-fno-exceptionsis anabort()— a node reboot when a large (e.g. composed-root) reply is assembled on a fragmented heap. A caller that knows its final link count reserves it here up front: on success the nextlinksappend calls are guaranteed non-reallocating hence nothrow.While the whole chain still fits INLINE (
have + links <= kInline) this is a no-op that touches neitherheap_nor the allocator — everyappendis then a pureinline_[]array write that cannot throw, so the hot small-reply delivery path (assemble) keeps its zero-alloc small-buffer fast path (ADR-0053 §6). Only once the chain WILL spill does it reserveheap_tohave + linksand migrate the inline links there, so no laterappendre-enters the inline→heap spillreserve(an empty rope that still spills keeps the reserved capacity, making even that one spillreservea no-op). On failure the rope is unchanged and the caller drops the reply (BACKPRESSURE) instead of aborting.The no-op arm is the ONLY thing this function body holds, so it stays cheap enough for the compiler to inline (#1065). The spilling arm — the
max_sizeguard, the allocator call and the inline→heap migration — lives in a separate out-of-line member. Charging the delivery clone a realcallfor a check that folds to one compare at a fresh 1-link rope is a fixed per-dispatch cost on the hottest leg the rope has.Note
#981 residual — the ONE thing this promise does not cover. “Instead of aborting” is exact on every profile whose growth THROWS, and exact on ANY profile while the chain still fits inline (that arm reaches no allocator). Once the chain spills under
-fno-exceptions, theheap_growth runs throughtr::detail::try_reserve, which there can only PROBE the global heap, free the probe block, and then run the throwingreserve— and a FreeRTOS context switch in that window lets another task take the block, so thereservehits exhaustion inside anoexceptand abort()s the node (#850). The chain cannot move to the ADR-0065 seam (tr::mem::block_array_t) as it stands:view_tis refcounted, and that container relocates bymemcpyand never runs a destructor. Closing it needs a failable array that relocates by MOVE (#873).- Return values:
false – Reservation failed (OOM / impossible count) — the rope is untouched.
-
inline std::size_t link_count() const noexcept¶
Number of links in the chain.
-
inline std::span<const view_t> links() const noexcept¶
The links, in order (inline small-buffer storage or the spilled chain).
-
inline const view_t &only() const noexcept¶
The single contiguous link — the consumer’s explicit “this value is
one segment” (ADR-0053 §6), zero copy.
Note
Precondition:
link_count() == 1(debug-asserted). A consumer that cannot promise contiguity calls materialize instead.
-
inline view_t materialize(mem::mem_backend_t &backend = mem::heap_backend()) const¶
The rope as one contiguous view_t — zero copy when single-link, one flatten copy otherwise.
The visible choice a contiguous-bytes consumer makes (ADR-0053 §6): a single-link rope is returned as its link (a refcount bump, no byte copy); a multi-link rope pays the single flatten copy from
backend. Distinct from flatten, which always copies — this keeps the trivial case free.Note
Lossy convenience (#917): a refused flatten comes back as an empty view, indistinguishable from a legitimately empty rope AND from the other refusal cause. A caller that must classify the failure (drop-vs-reply, transient-vs-permanent) calls try_materialize.
-
inline std::expected<view_t, flatten_err_t> try_materialize(mem::mem_backend_t &backend = mem::heap_backend()) const¶
materialize with the failure cause kept distinct (#917).
Same tiering as materialize — a single-link rope IS its link, zero copy (handed back as-is even for a DEVICE link, exactly as materialize does: no CPU dereference happens here); a multi-link rope pays one try_flatten copy. The difference is the error channel: a success carrying an empty view means the rope really is zero bytes, while a refusal names its cause, so an OOM stays TRANSIENT backpressure and a DEVICE payload is never misfiled as a malformed frame.
-
inline std::size_t total_length() const noexcept¶
Total logical length across all links.
-
inline bool all_host() const noexcept¶
True when every link is CPU-addressable (HOST).
A
falserope is heterogeneous — it has a DEVICE link (e.g. a GPU payload, docs/adr/0024) that the CPU must not dereference, so host-side operations (flatten, CRC) cannot touch it.
-
inline rope_t subrope(std::size_t off, std::size_t len) const¶
The
[off, off + len)sub-range as its own rope (chaining — no copy).Trims the covering links with view_t::subview, so the result shares (refcounts) exactly the segments its window touches and keeps only those alive — the region primitive of the lazy decode tier (ADR-0053 §1): a child TLV, a routed path suffix, or a payload handed onward is a subrope of the inbound frame, never a copy of it.
Note
Precondition:
off + len <= total_length()(debug-asserted via the subview window invariant; a shorter tail yields a shorter rope).
-
template<class Fn>
inline void walk(Fn &&fn) const¶ Visit each link’s contiguous bytes in order (parsers, serializers, CRC).
-
inline std::vector<std::span<const std::byte>> to_iovec() const¶
Scatter-gather egress: spans into the original segments (no copy).
Hand the result to
writev/sendmsg-style I/O for true zero-copy transmit.
-
inline bool try_to_iovec(std::vector<std::span<const std::byte>> &out) const noexcept¶
Nothrow to_iovec — fill
outwith one span per link (no copy), soft-failing instead of aborting when the span table cannot be grown.The
reservein to_iovec throws on OOM (anabort()under-fno-exceptions); the terminus reply egress builds this table per send, so on a fragmented heap that aborted the node. This nothrow-reservesoutto link_count first and drops the reply on failure.outis cleared on entry.Note
#981 residual:
std::spanIS trivially copyable, so unlike the link chain this table could sit on the ADR-0065 seam — butoutis caller storage of a type this signature fixes, so the migration is an API change (atr::mem::block_array_toverload plus a source at every caller), not an edit here. Until then the growth keepstr::detail::try_reserve’s-fno-exceptionsprobe window: a task switch between the probe’s free and thereserveabort()s the node (#850). The router’s own egress iov tables took exactly that migration in #981 and no longer route through this helper.- Return values:
false – The span table could not be reserved —
outis left empty.
-
view_t flatten(mem::mem_backend_t &backend = mem::heap_backend()) const¶
Materialize the rope into one contiguous segment from
backend(one copy).The single bridge-boundary copy — taken only when a flat-buffer consumer demands it. The flattened view can then be cast with
decode(view_t)(frame.hpp,tr::wire).Note
Lossy convenience (#917): both refusals collapse into the empty view, which a zero-length rope also returns on SUCCESS. A caller that must tell them apart calls try_flatten.
Note
Kept OUT OF LINE, and computed directly rather than by unwrapping try_flatten’s
expected— both halves of that shape cost real time (#1250). Wrapping cost a copy: the wrapper held aconst expected, sostd::move(*r)yielded aconst view_t&&that bound the COPY constructor — two extra atomic refcount RMWs per flatten. Defining it here cost the CALLER: the body’s 32 Bexpectedtemp plus its stack canary pushed materialize past this toolchain’s inline threshold, so even the single-link zero-copy arm — which never flattens at all — paid an out-of-line call. Together, 25–48% on everymaterializepath.- Return values:
{} – An empty view if the backend cannot allocate, **or if the rope is not all_host** (a DEVICE link cannot be CPU-memcpy’d — docs/adr/0024; lower such a payload via its device transport).
-
std::expected<view_t, flatten_err_t> try_flatten(mem::mem_backend_t &backend = mem::heap_backend()) const¶
flatten with the failure cause kept distinct (#917).
The honest form of the bridge-boundary copy: an empty view on the value side means the rope really is zero bytes (a valid, if degenerate, flatten — no segment is allocated for it), and a refusal names WHICH refusal it is, flatten_err_t::NOT_HOST (permanent for this rope) vs flatten_err_t::NO_MEMORY (transient backpressure). Collapsing those into one empty view is what let a local OOM be reported as a malformed frame (#917).
-
inline rope_t &concat(const rope_t &other)¶
-
inline std::optional<view_t> tr::view::over_bytes(std::span<const std::byte> bytes) noexcept¶
Allocate a fresh heap segment, copy
bytesinto it, and return a view over it — the canonical “own a copy of these bytes as a view_t” idiom (heap_alloc + memcpy + view_t::over) in one place.Collapses the repeated alloc/copy/over triplet across the codec and runtime (graph read_schema/read_acl, the FWD resolver’s WRITE-payload and reply head, fwd_router’s local delivery) into one audited locus.
The return type disambiguates the two outcomes an unowned
view_tused to conflate (docs/reference/08 §L1 contracts):std::nulloptis an allocation failure the caller maps to BACKPRESSURE; an engaged, empty view is a legitimately-emptybytesspan (no allocation is attempted).- Return values:
std::nullopt – Allocation failure / backpressure.
{engaged} – An owned copy of
bytes(empty-and-unowned iffbytesis empty).
-
inline std::optional<view_t> tr::view::over_bytes(std::span<const std::byte> bytes, mem::mem_backend_t &backend) noexcept¶
The seam-taking over_bytes (#793): own a copy of
bytesin asegmentdrawn frombackendrather than from the global heap.Same contract, same two outcomes; the only difference is where the bytes come from. It exists for the ADR-0041 §2 ownership copies on a PEER-DRIVEN path — the rope-tier
view_node::own_wire’s single-link branch was still copying through the global heap after #766 seamed the multi-link branch beside it, so one function drew from two different allocators depending on how the peer fragmented the frame. #801 took the span tier’sarena_node::own_wirethrough the same overload: the two tiers must not differ on WHERE a stored value’s bytes come from, since which one runs is decided by the delivering transport (a rope-delivering child vs a span-delivering one) and not by anything the application chose.- Why an overload and not a defaulted parameter
A defaulted
mem_backend_t& = mem::heap_backend()reads better and was the first shape tried. It moves theheap_backend()call out ofheap_allocand into every existing call site, and the library’s object files stop compiling to the same bytes (measured: 8 of them changed, +0.1 % text). The dynamic call count is unchanged and the difference is almost certainly unmeasurable — but “almost certainly” is not the standard here, and the separate overload makes the pre-#793 arm provably untouched: every object file that does not opt incmps equal.
- Parameters:
bytes – The bytes to own a copy of.
backend – The injected byte seam. A refusal answers
std::nullopt— the same by-value BACKPRESSURE channel an OOM already used, never an abort.
- Return values:
std::nullopt – The backend refused (or
bytescould not be owned).{engaged} – An owned copy of
bytes.
-
segment_ptr_t tr::view::segment_alloc(mem::mem_backend_t &backend, std::size_t size)¶
Allocate a fresh, owned segment of
sizebytes frombackend, wrapped in an adoptingsegment_ptr_t— the backend-taking form heap_alloc is one call of (#793).An L1 helper (it produces an owning handle), so it lives in
tr::view, nottr::mem(docs/adr/0016 §2). It exists so an ownership COPY on a peer-driven path can draw from the node’s injected byte seam instead of the global heap — the rope-tierown_wire’s single-link branch was the first such copy converted (#793), the span-tierarena_node::own_wirethe second (#801); the multi-link branch beside the former already flattened through the injection (#766).- Return values:
{} – An empty handle on allocation failure (the backend refused).
The CAN splitter is the other L1 view producer; it lives with the rest of the CAN stack on can. The lazy, rope-backed decode view — what a rope-delivered frame becomes on the read side — is L2 and lives on frame-codec.
See: segment, frame-codec, graph, can, reference 08 — views and ownership.