Framing Design Notes¶
Status: working notes. A running record of the framing design decisions taken while implementing
rbe::frame,rbe::anyandrbe::manyon thefeat/framing-v2branch, meant as the source material for the future user-facing framing documentation. Not linked from site navigation — same convention as Message Framing Redesign and Annotation System Redesign.Where these notes and framing.md disagree, these notes are newer; the known divergences are listed in Divergences from framing.md.
Each decision is tagged:
- Implemented — in the code and covered by tests.
- Decided — agreed, not implemented yet.
- Proposed — recommended, still pending confirmation.
- Open — no answer yet.
1. Levels: vocabulary types and their lowerings¶
Implemented. The user describes a protocol with vocabulary types in rbe:: — rbe::frame<H, P>,
rbe::any<Ts...>, rbe::many<T>, rbe::blob. They carry no bytes; they describe the wire format and
lower to the types that actually view or write bytes:
Vocabulary (rbe::) |
dsrl_type |
srl_type |
|---|---|---|
frame<H, P> |
dsrl::frame<H, to_dsrl_t<P>> |
srl::frame<...> (TODO) |
any<Ts...> |
dsrl::any<Ts...> |
srl::any<...> (TODO) |
many<T> |
dsrl::many<T::dsrl_type> |
srl::many<...> (TODO) |
blob |
std::span<std::byte const> |
std::span<std::byte> |
wirable T |
T (the strategy decides the return type) |
T |
- The header is never lowered, only the payload (
rbe/frames/detail/to_dsrl_type.hpp). - Lowering recurses through nesting and is idempotent.
Implemented. Wire-format properties are level-agnostic. Classifying how a frame is delimited describes
the format, and srl needs it as much as dsrl (which length field to back-patch, whether a run of frames
can be split by a reader), so the classification is written once against the shape of a frame and holds at
every level: self_delimiting_frame<msg_frame> and self_delimiting_frame<msg_frame::dsrl_type> are both
valid and give the same answer, because lowering never changes how a frame is delimited. Errors still show up
on the type the user wrote, since rbe::frame constrains its own parameters.
Concept layering¶
Implemented. The concepts used to be written three times, once per level. They are now split along what actually differs between levels:
| Concept | Where | Holds for |
|---|---|---|
frame_header |
concepts.hpp |
every level — a header is never lowered |
is_frame |
concepts.hpp |
every level — header_type + payload_type, nothing else |
frame_payload |
concepts.hpp |
dsrl and srl — the payload categories over the bytes |
self_delimiting_frame, buffer_delimited_frame, dispatch_delimited_frame, … |
frame_delimiting_concepts.hpp |
every level, via is_frame |
frame_serder_payload, serder_traits, frame_serder |
frame_serder_concepts.hpp |
the vocabulary level |
dsrl::is_frame |
dsrl/concepts.hpp |
the deserializer API |
srl::is_frame |
srl/concepts.hpp |
the serializer API (TODO) |
is_frameis the weakest thing worth calling a frame, and the only thing the delimiting classification needs. Everything a level adds on top (traits to lower itself, an API to offer) stays with that level.frame_payloadis spelled overstd::span<std::byte>only: a payload type constructible fromstd::span<std::byte const>is constructible fromstd::span<std::byte>too, which converts to it, so the writable span covers both lowerings and the concept does not need to be parameterized by the level.- The vocabulary payload (
frame_serder_payload) is not that list of categories: at that level a payload is either a wirable message or a type that lowers itself (serder_traits), which is whatblob,many,anyand nested frames have in common. The short names belong to the shared core; the vocabulary level, which is the one that owns the serder traits, keeps the_serderprefix. is_any/is_manyare detected by a tag base (rbe::detail::any_tag,many_tag), which both the vocabulary type and its lowering inherit publicly —std::derived_fromneeds the base to be reachable, so aclassvocabulary type must saypublic. Now that they are real,serder_traitscould fold into the same disjunction andframe_payload/frame_serder_payloadbecome one concept.
2. A frame is a view; the buffer may be larger than the frame¶
Implemented in rbe/frames/dsrl/frame.hpp.
dsrl::frame and dsrl::proxy are non-owning views over a std::span<std::byte const>. Both narrow their
span at construction, so they always view exactly their object (data_.size() == length()). Construction
resolves the layout (length fields, static sizes, nested frame lengths); header and payload values stay
lazy.
The buffer may be larger than the frame, and this is the normal case, not a tolerated one:
- The library itself builds frames over larger buffers:
payload_length()of a nested frame builds the inner frame over the remainder of the buffer, because it does not know the inner length yet — that is what it is computing. - Iterating
manybuilds each element over the rest of the buffer, reads its length and advances. The exact size is only known after building the element. - Users rarely know the exact size: they get a datagram, a
recvbuffer, a ring buffer. Demanding the exact size would force them to parse the header themselves.
What needs controlling is a buffer that is too small, and ambiguity about what the buffer size means (see §4).
Accessor contract:
| Accessor | Returns |
|---|---|
length() |
data_.size() — resolved once at construction by length_of() |
header_length() |
the header_length field, otherwise wire_size_of<header_type>() |
payload_length() |
length() - header_length() |
header_span() |
data_.first(header_length()) |
payload_span() |
data_.subspan(header_length()) |
as_span() |
data_ — exactly the frame |
data() |
std::byte const* to the start of the frame |
Constructor preconditions (documented on dsrl::frame::frame):
data.size() >= length_of(data), checked bystd::span::first()in hardened builds- buffer-delimited frames: the span covers exactly the frame
wire_size_of<header_type>() <= header_length <= frame_length
Implemented — length_of(buffer). A static frame::parse_length(data) resolves the length without constructing
the frame, so a partially received buffer can still tell how many bytes it needs (stream reassembly). It only
reads what the length depends on: the fixed-size header prefix for length fields and static sizes, plus the
nested frames' headers for a nested frame payload. For a buffer-delimited frame it returns data.size(). The
constructor narrows with it (data.first(length_of(data))); members cannot be used there, since they read
data_ before it is initialized. dsrl::is_frame requires it, because a nested frame payload is resolved
through payload_type::parse_length().
Proposed — checked construction. Keep the constructor unchecked (a contract, checked in hardened builds)
and add a validating factory, used internally by many:
It would check the fixed header prefix, then length_of() against the buffer size (reporting "incomplete"
instead of constructing), then length consistency. The return type
(optional, expected, a result struct) is Open.
Decided — proxy narrows its span at construction. Unlike frame, a proxy always views exactly its
object: data_.size() == length().
- Fixed-size
T:data.first(wire_size_of<T>()), which is free. - Variable-size
T: the constructor builds the offset table, which yields the length (§8).
The span passed in may still be larger than the object; the extra bytes are simply not kept. This makes
as_span() just data_; size()/size_bytes() are removed, since length() is the object's size. It supersedes the
earlier proposal of storing the whole span and narrowing in the accessors, which only made sense for a lazily
computed layout. Remaining inconsistency:
data()returns aspaninproxybut astd::byte const*inframe.
3. Length resolution¶
Implemented. Three length annotations, one per quantity (rbe/annotations/length.hpp):
header_length, payload_length and frame_length, related by
frame_length = header_length + payload_length.
header_length()— the annotated field, otherwisewire_size_of<header_type>(). The field is read over the fixed-size header prefix (data_.first(wire_size_of<header_type>())), never throughheader_span(), which depends onheader_length()itself.length()— resolved once at construction by the staticlength_of(), from the payload extent in priority order (wire values before static sizes):
payload_extent |
Payload length |
|---|---|
payload_length_field |
the payload_length header field |
frame_length_field |
frame_length field − header_length() |
nested_frame |
the nested frame's own length_of() over the remainder of the buffer |
static_size |
wire_size_of<payload_type>() — fixed-size wirable payloads only, see §8 |
any_id (Decided, not implemented) |
the size of the alternative selected by the header id |
buffer_end |
to the end of the buffer (blob, span-constructible, many) |
payload_length()—length() - header_length(), by the identity above.
The classification is a single compile-time function, rbe::detail::payload_extent_of<H, P>() in
rbe/frames/detail/payload_extent.hpp. dsrl::frame::parse_length() branches on it with if constexpr,
and the concepts in §4 are built on it, so the runtime
resolution and the compile-time classification cannot drift apart.
The classification is structural: it only needs header_type and payload_type
(rbe::is_frame), so an rbe::frame and its dsrl::frame lowering classify identically. That
invariant is static-asserted (same_extent_when_lowered in tests/static/test_framing.cpp).
Open — units. All four annotations count bytes. IPv4's IHL counts 32-bit words, and some protocols count elements rather than bytes (see framing.md).
4. Self-delimiting and buffer-delimited frames¶
Implemented in rbe/frames/frame_concepts.hpp:
template<typename T>
concept self_delimiting_frame = is_frame<T> and detail::is_self_delimiting<T>();
template<typename T>
concept buffer_delimited_frame = is_frame<T> and not detail::is_self_delimiting<T>();
- Self-delimiting: the frame's length is known without looking at the buffer size — from a length field, static sizes, a nested self-delimiting frame, or (once implemented) the header id. Such a frame may be read from a larger buffer; trailing bytes are padding.
- Buffer-delimited: the payload runs to the end of the buffer, so the buffer size is the frame length. Trailing bytes are taken as payload, never as padding.
Rules, all covered by static asserts:
- A
payload_lengthorframe_lengthfield makes the frame self-delimiting, whatever the payload is. Guaranteed by the priority order ofpayload_extent_of: a length field is classified before the payload is even looked at.frame<WithFrameLength, blob_frame>is self-delimiting even thoughblob_frameis not. - A nested frame makes the outer frame self-delimiting only if the nested frame is.
header_lengthalone does not delimit anything: it bounds the header, and the payload still runs to the end (frame<WithHeaderLength, blob>is buffer-delimited).- Both concepts only accept
rbe::frame_serdertypes, not their lowerings.
Concepts cannot be recursive, so the walk through nested frames lives in the consteval
rbe::detail::is_self_delimiting<F>().
Proposed — explicit is_self_delimiting. Today it returns extent != buffer_end for everything except
nested_frame, which reads as "depends on the payload" and would silently classify a future enumerator as
self-delimiting. Enumerate each case instead (length fields → true, static_size → true,
nested_frame → recurse, buffer_end → false), so that adding any_id forces a deliberate branch.
Buffer-delimited frames are legitimate¶
Supporting them is deliberate. The length always exists, but not always inside the frame itself — it is often provided by the enclosing layer:
- TCP: the header has a data offset (
header_length) but no payload length; the segment size comes from IP's total length. Exactlyframe<TcpHeader, blob>. - RTP, CoAP, DNS over UDP, a lot of proprietary telemetry: no length, the payload is the rest of the UDP datagram.
- Ethernet II: no length field (EtherType); the size comes from the driver.
- Application protocols that send one message per datagram and never needed a length because UDP carries it.
The restriction is where they may appear, not whether they exist:
- As the outermost frame — the buffer is exactly what was received.
- As the payload of a frame carrying a length — the outer frame narrows, and the inner one receives
exactly its
payload_span():frame<IpHeader /* [[=frame_length]] */, frame<TcpHeader, blob>>. - Never as elements of
many— nothing tells where one ends and the next begins.
Proposed — documentation wording for buffer_delimited_frame:
Valid only where something else delimits them: as the outermost frame (the buffer is exactly what was received), or as the payload of a frame that carries a length. They can never be elements of
rbe::many.
The typical failure is a fixed-size receive buffer (e.g. 1500 bytes) not trimmed to the size recv
returned. It is harmless for self-delimiting frames; for buffer-delimited ones the garbage becomes payload.
Types cannot prevent it; the constructor documentation warns about it.
Out of scope for frame: byte-stuffed protocols (SLIP, COBS, HDLC). Their length comes from a deframer
scanning for a delimiter, not from a header. They still fit as "outermost frame over what the deframer
returns", but deframing is not frame's job.
many requires self-delimiting elements¶
Decided, not implemented. rbe::many<T> (and its lowerings) must require self_delimiting_frame<T>.
It is blocked on any_id: many<frame<CommonHeader, any<...>>> (packet in tests/static/test_framing.cpp)
currently classifies as buffer_end, because any is still a stub and CommonHeader has no length field.
Constrain many once any_id exists.
5. any with id-implied lengths (any_id)¶
Implemented (classification). rbe::any<Ts...> counts as self-delimiting when every alternative is
(fixed-size, or itself delimited). payload_extent_of() returns any_id for an any payload and
rbe::dispatch_delimited_frame picks those frames out, so callers can tell apart the frames whose length()
costs an id dispatch; resolving the id at runtime is still pending. Open: the classification does not yet
check that every alternative does imply its length, so an any holding a variable-size alternative would be
classified any_id anyway. Users need many<frame<Header, any<Msgs...>>>, and many protocols imply each message's length
from its id rather than a header field. When the header carries both a length field and an id, the length
field wins: it is cheaper and tolerates unknown ids (see §6).
Cost and resolution¶
Self-delimitation is a compile-time property; the cost of computing the length is a separate, runtime axis,
and it is not encoded in the concepts (reading a payload_length field has a runtime cost too). The
payload_extent enum still lets code pick a strategy at compile time
(if constexpr (extent == payload_extent::any_id)).
Decided — id→index resolution. C++26 reflection cannot generate a switch (no code injection). Use a
template for chain of if comparisons and let the optimizer turn it into a jump table, bit test or decision
tree; GCC and Clang do so at -O2 when the pattern is clean. Even a linear scan over ≤30–50 integer
comparisons costs a few nanoseconds, and real message distributions are skewed, so the branch predictor learns
the hot types.
Keep the chain switch-convertible:
static constexpr auto ids = std::array {id_of<Ts>()...}; // from [[=rbe::id(v)]]
static constexpr auto sizes = std::array {rbe::wire_size_of<Ts>()...};
auto const id = /* read the [[=rbe::id]] header field once */;
template for (constexpr auto i : indices) {
if (id == ids[i]) {
return i; // only return the constant: no work inside the branch
}
}
return npos; // unknown id
- Read the id once into a local before the chain. If every branch re-reads the buffer, the optimizer no longer sees the pattern.
- Branches only return the index. The length comes afterwards from
sizes[i], and dispatch is a separate step.
Fallbacks, only if profiling calls for them (both generated in consteval):
- Dense, small ids (e.g.
uint8_t): an id→indexstatic constexpr std::arraywith a sentinel. O(1) guaranteed. - Dispatch through a function-pointer table, as
std::visitimplementations do. It is an indirect call, though, which blocks inlining the visitor, so theifchain likely still wins.
Verify once implemented by checking the generated assembly for the jump table (objdump -d, Compiler
Explorer).
Resolve once, carry the index¶
dsrl::frame is stateless and recomputes on every accessor (as_span(), payload_span(), match() all end
in payload_length()). With a length field those are cheap reads; with any_id each is a lookup. So the
index is resolved once and carried, without adding state to frame:
dsrl::anyis built from(index, span):length()becomessizes[index], andmatch()an indexed jump.frame<H, any>::payload()resolves once and builds the resolvedany.- The
manyiterator resolves while advancing (it needs the length to step) and keeps the index, so dereferencing yields a frame with an already resolvedany.
Caching the index inside frame is rejected: it stops being a trivial, copyable view and needs an "unresolved"
state.
6. Unknown ids¶
What sets any_id apart is not the cost but that resolution can fail:
- With a length field, an unknown id is skippable: the stream stays parseable.
- With
any_id, an unknown id has no length: the rest of the buffer is unparseable.
Proposed:
- No exceptions. The library must build without exceptions (REQ-040/041 in Requirements). An unknown id is not exceptional either: a newer protocol version adding messages is routine. A user who wants to throw can do so from their default handler.
- No
optionalfrompayload_length(). It keeps returningsize_type. Forany_idit has the precondition "the header id is known", checked in hardened builds (REQ-042..044) and assumed in non-hardened ones (REQ-046). This is the same kind of contract as "the buffer covers the frame". - The failure channel lives in resolution, as a sentinel index (
npos, likestd::variant::nposor afindreturningend), not anoptionalwrapper. The two safe paths —match()and themanyiterator — have to resolve anyway, so checking the sentinel costs nothing. Only direct misuse (callinglength()on a frame whose id is unknown) can hit the precondition. match()sends an unknown id to the default case. The default case is how the user learns about it, and the documentation must make users aware of what happens next.- Iteration over
many: - explicit lengths: the unknown element goes to the default case, and iteration continues;
- id-implied lengths: the unknown element is still yielded (its header is readable), goes to the default case, and iteration then ends, because the iterator cannot advance past it.
Same handler, different continuation depending on the payload extent — this is the key point for the user
documentation.
- The default handler must receive something safe for an unknown id: the id and the header proxy, and
possibly the remaining bytes — never an API whose precondition is already broken (as_span(),
payload_span()).
Open:
- How a caller tells "the buffer was fully consumed" from "iteration stopped at an unknown id" after the loop.
Candidates: rely on the default handler having run, or have
manyexposeremainder()/consumed(). - The contract mechanism for hardened checks (C++26
pre/contract_assert, or a project macro), and whether it is shared with the other framing preconditions.
7. Iteration model¶
Decided. many ships a pull model (external iterator, ranges-compatible) first; a push model
(for_each/dispatch with an overload set) is a future opt-in, and no API may block adding it.
- Pull supports ranges, and resuming over partially received buffers.
- With id-implied lengths, pull costs one id resolution to advance plus an indexed jump on match, where push would need a single resolution. With explicit lengths pull is cheaper: no resolution to advance, and a lazy one on match.
See §5 for how pull pays the id resolution only once.
8. Variable-size wirable types¶
The detailed design — offset table, anchors, nested structs, resolution and a worked example — lives in Variable-Size Layout Notes. This section keeps the summary and the impact on framing.
Decided (direction), not designed. proxy, and the rest of dsrl/srl, will have to support structs whose
wire size is only known at runtime — e.g. a std::vector<Element> whose element count comes from another,
earlier member. The annotation side is sketched as count(field_name) in
Annotation System Redesign. The count
source must precede the vector on the wire (fixed field order, REQ-051/052).
This does not change the framing model; it pushes the same question one level down, from frames to structs: where does the length come from, and can it be known from the object's own bytes?
Consequences to keep in mind now:
wire_size_of<T>()stops being total. Variable-size types need a static minimum (the fixed prefix up to the first variable member) plus a runtime length computed over the bytes. Headers keep requiring a fixed size:header_length()reads its field over the fixed prefix.proxy::length()becomes a runtime quantity for variable-sizeT, but it is known from construction on (below), so it stays a plainconstaccessor.- Field offsets become runtime past the first variable member. Today
field<Index>()slices with static offsets (data_.subspan<offset, size>()). A member after a vector sits at "static offset + sum of the preceding variable parts": - with fixed-size elements, that is arithmetic on counts (
count × element size); - with variable-size elements, it is a forward walk.
- Accessing the vector lazily yields a range over the element bytes: random access when the element size is
static, forward-only otherwise — the same categories as
many.in_placestays unavailable, since such a type is not trivially wirable. payload_extenthas to splitstatic_size.payload_extent_ofclassifies every wirable payload asstatic_sizetoday. A variable-size wirable payload needs its own case — "the payload computes its own length from its bytes", the struct-level counterpart ofnested_frame— which is self-delimiting when its counts live inside it.
Offset table built at construction¶
Decided. A variable-size proxy computes its offset table in the constructor, and every member stays
const. field<"vector">() and every other accessor are used exactly as with a fixed-size T, with no extra
step for the user.
- One class, specialized storage. A single
proxykeeps one field-lookup API (field<"name">,field<Annotation>,field<"a", "b">,field<Index>), which goes through anoffset_of(index)helper instead of readingmember_layout.offsetdirectly: - members before the first variable-size member keep their compile-time offset;
- the table holds one entry per variable-size member (its end offset), so its size is known at compile time; a fixed member between two variable ones sits at that end plus a static delta;
- for fixed-size
Tthe table is an empty type ([[no_unique_address]]), so thatproxystays exactly as it is today. - Semantics do not depend on
T. Every member means the same for fixed- and variable-size types; only costs differ (construction,sizeof). A different type is reserved for a different contract, not a different cost. - Lazy means values, not layout. Field values are still decoded on demand, one at a time, with no copy of
the object (REQ-011..013). For variable-size
Tthe layout is resolved eagerly: construction reads only the counts needed to place each member, never the values. The cost is zero for fixed-sizeT, N count reads with fixed-size elements, and a walk over element counts with variable-size elements.framealready works this way: it reads the header length fields to locate the payload. - To document for variable-size
T: - construction reads the counts and walks the variable-size members, even if only a prefix field is used afterwards;
- the precondition "the buffer covers the object" is hit at construction, which is where the hardened check goes;
- a proxy cannot be built over a partially received object;
- the layout is frozen at construction: the viewed bytes must not change while the proxy is alive. Changing a
count afterwards (e.g. reusing the buffer for the next message) leaves stale offsets that can point outside
the span, whereas
frameand fixed-size proxies would only observe new values; - thread safety is unaffected: construction only reads the shared buffer, and afterwards every member is
constwith nomutablestate, so concurrent calls on the same proxy are safe as long as nobody writes the buffer; sizeof(proxy)grows by one offset per variable-size member.- Nested variable-size structs: the parent's table gives the child its exact span, but the child builds its own table again. A private constructor taking the already computed entries can remove that double walk later, if profiling calls for it.
Rejected alternatives:
- A lazy table in a
mutablemember. It would spare prefix-only reads the construction cost, but caching behindconstneedsmutable, which is only justified for synchronization primitives such as a mutex; it would also break concurrentconstaccess to the same object. - Dropping
constfrom the accessors to allow a lazy cache, asstd::ranges::filter_view::begin()does: the accessors do not change the object's observable state. - Recomputing on every access plus an explicit
resolve(): it pushes an extra step onto the user, and variable-size types would no longer be used exactly like fixed-size ones.
Open:
- Counts outside the struct, e.g. a header field sizing a payload's vector. The payload alone is not
self-delimiting and its length depends on the header, which crosses levels. This is related to the
repeat-count annotation for
manyin framing.md. - A trailing vector with no count (sized by the end of the buffer) would make the struct buffer-delimited.
Probably not allowed at struct level, since
frame<H, blob>already expresses it.
9. Uniform field access across strategies¶
Decided, not implemented. Code that is generic over the deserialization strategy — benchmarks comparing
lazy, eager and in_place above all, and strategy-parameterized tests — gets a uniform interface through a
free accessor, not through a single proxy<T, S> type.
Why not proxy<T, S>¶
framing.md sketched proxy<T, S = lazy_t>, reached through
deserialize<T>(buf, as_proxy(eager)), with the same field<"name">() interface for every strategy. It mixes
two contracts under one name:
proxy<T, S> |
Depends on the buffer after construction |
|---|---|
lazy_t |
yes: a view over the bytes |
in_place_t |
yes: a reference into the bytes |
eager_t |
no: it would own a materialized T |
return_type<S, T> already mixes an owned value (T), a reference into the buffer (T const&) and a view
(proxy<T>), and that is fine, because each type states its semantics. The conflict only appears when all three
share the name proxy, which promises "I am a view" and would not be one in eager mode. What needs unifying is
how fields are accessed, not the type.
The free accessor¶
Every strategy keeps returning its natural type, and a free function works on all of them, the way std::get
works on both tuple and variant:
namespace rbe {
// T, T&, T const& -> reference to the member (reflection-based access)
// dsrl::proxy<T> -> forwards to proxy.field<Path...>()
template<static_string... Path>
constexpr decltype(auto) field(auto&& source);
template<unique_annotation auto Annotation>
constexpr decltype(auto) field(auto&& source); // same, looked up by annotation
}
template<strategy S>
void bench_price(std::span<std::byte const> buf, S strategy) {
auto&& msg = rbe::deserialize<Order>(buf, strategy); // T, T const& or proxy<T>
do_not_optimize(rbe::field<"price">(msg));
}
- No mixed contracts.
proxyis always a view,Talways owns its data,T const&always refers to the buffer. - Nothing to invent for eager. There is no owning
proxy. - No overhead for eager and in_place.
rbe::field<"price">(msg)reduces tomsg.price, so a benchmark does not measure an artificial wrapper. Still check it: measure raw access as a baseline, or confirm in the assembly that the accessor disappears. - Framing gets it for free.
frame::payload(S)already returnsreturn_type<S, T>, so generic code over frames uses the same accessor. auto&&covers all three returns: it extends the lifetime of the eager temporary and binds the in_place reference and the proxy directly.- To document: the result type is not identical across strategies. Owned values and references yield a reference to the member; a proxy yields a decoded value, or a nested proxy for class-type members.
Hand-written code keeps the raw returns: msg.price is still better than msg.field<"price">().
Eager versus lazy for variable-size types¶
Which backend wins is workload-dependent, which is exactly what the benchmarks are for. One point to keep in mind: eager does not benefit from lazy's offset table, since it reads sequentially and picks up the counts while copying. The table is a cost specific to lazy, not a cost eager gets for free. On top of lazy, eager adds:
- Allocation: every
std::vectorgoes to the heap, which usually dominates in hot paths and conflicts with heap-less or freestanding builds; - Copying and converting every element: O(N) at construction, versus O(1) per accessed element for lazy, which hands out a zero-copy range.
Eager tends to win when almost every field and element is read and allocation is acceptable. Lazy tends to win for sparse access, such as filtering network messages on a couple of fields, and whenever the heap is off limits.
Rejected alternative — a proxy viewing a materialized T (rbe::view_of(value)): no proxy would own
anything, only its lifetime dependency would change (buffer or object). It is more coherent than an owning
eager proxy, but the free accessor solves the same need with fewer pieces.
10. Header-only messages¶
Open. Some protocols have messages that are only a header, such as aquis::Heartbeat and opra::Control.
With the header declared once on the frame, such a message has an empty payload, and an empty struct is not
wirable, so it cannot be an alternative of rbe::any. The market examples leave them out of their messages
with a TODO.
Candidates:
- Make an empty class wirable, with a wire size of 0, so it participates in
anylike any other alternative. - Let
rbe::anyaccept an id with no payload type.
11. Custom frames: public building blocks and a minimal base_frame¶
Decided (direction), not implemented. rbe::frame<H, P> composes well as long as a protocol fits the
header-plus-payload shape and one of the payload categories frame_payload lists. What it does not offer is
a way to customize a frame: dsrl::frame is the orchestrator that knows every payload category
(narrow_to_payload / construct_payload branch on is_any, explicitly_delimited_frame, …), and those
steps are private members, so a delimitation rule the library does not know about — a line-oriented
ASCII protocol, OPRA's (msg_category, msg_indicator) discriminant, a length carried inside the body —
cannot be expressed without editing frame or copying its internals.
The motivating case is cboe TOP (example/markets/cboe.hpp), a line-oriented ASCII protocol: a line
frame wants its length to include the '\n' bytes that follow the message, so that many<line> advances
line by line rather than by candidate size — and, as a side effect, no longer stops at an unknown
msg_type.
The decision is to keep the payload contract as it is and open the frame instead: not through
inheritance, but by lifting the steps frame performs out into public building blocks that any frame —
the library's or the user's — composes.
Why not a richer payload contract¶
A redesign was evaluated and is kept here so the analysis is not lost. Every payload view would carry two
forms of construction — a wide make(bytes) / make(header<H> const&, bytes) ("find your own extent") and
a narrow {bytes, rbe::exact_length} ("this span is you", REQ-141..144) — plus a length(), which
headermake, and a payload with no make is buffer-delimited. A wirable payload would be
wrapped in a payload<T> mirroring header<H> (proxy<T> plus the length the frame assigned,
with known_span() / extension_span() for SBE-style trailing bytes), payload_extent_of would gain a
single payload_resolved enumerator ("the view offers make") in place of static_size / nested_frame
/ any_id, and any would absorb its own special case through make(header<H> const&, bytes).
It does solve the size() / length() asymmetry and lets frame host a user payload with no
is_any-style branch, but it puts a contract on every payload to serve cases that a custom frame covers
just as well. It stays on the table if several protocols turn up that do not fit the frame-level
extension below.
A frame is three steps, and each one is a public building block¶
Everything dsrl::frame::make does decomposes into three steps. Today the first is public and the other
two are private members of frame, which is exactly what makes writing a frame of one's own impossible
without copying them.
| Step | Today | Becomes | Who needs to replace it |
|---|---|---|---|
| 1. Where the payload starts | header<H>::make(buf) + header<H>::length() |
unchanged, already public | nobody so far; header-length units (IPv4 IHL in words) would be the header's business |
| 2. Where the payload ends | frame::narrow_to_payload (private) |
dsrl::payload_extent(hdr, rest) -> buffer_type |
TOP ('\n'), OPRA (msg_category + msg_indicator), OPRA Administrative (length inside the body) |
| 3. What is built over those bytes | frame::construct_payload[_hardened] (private) |
dsrl::try_construct_payload<P>(hdr, bytes) -> std::optional<P> |
anyone wanting a view of their own instead of any / proxy<T> |
payload_extent(hdr, rest)is today'snarrow_to_payload: the header'spayload_length()when it declares one; otherwise the rest, and the view built in step 3 narrows itself if it can. Keeping it separate from step 3 is what lets a custom frame swap the extent while keeping the payload — the common case.try_construct_payload<P>(hdr, bytes)is today'sconstruct_payload_hardened: wirableT→proxy<T>::make,any→any::make(hdr.id(), bytes), which checks the candidate fits and narrows to its wire size — ananyis a variant ofproxy, a view over the candidate it knows, and the bytes a header declared beyond it stay reachable through the frame'spayload_span()— nested frame →frame::make, span → the span. It is the one place that knows how each payload category is constructed from a header and its bytes, and being public it is the one place a user frame has to call to interoperate withanyand the rest.dsrl::frame<H, P>::makebecomes the canonical composition of the three, and nothing else:
static constexpr auto make(buffer_type data) -> std::optional<frame> {
auto const hdr = header_type::make(data);
if (not hdr) return std::nullopt;
auto const bytes = payload_extent(*hdr, data.subspan(hdr->length()));
return try_construct_payload<payload_return_type>(*hdr, bytes).transform([&](auto p) { return frame {*hdr, p}; });
}
The "fast path" make takes today for explicitly delimited frames (skip the fit check) folds into
try_construct_payload, which can see header<H>::has_payload_length / has_frame_length itself.
dsrl::base_frame<H, P>: only what is common¶
With the steps public, the base has nothing to resolve. It is the std::ranges::view_interface of frames,
reduced to storage plus the derived operations, dispatching to the most derived type with deducing
this:
| Primitives (the derived class) | Derived operations (base_frame) |
|
|---|---|---|
| What | static make(buffer) -> std::optional<D>, length() const |
header(), payload(), data(), buffer(), as_span(), header_span(), payload_span(), flatten() |
| State | whatever it needs (e.g. line_length_) |
header_ + payload_ |
base_framehas neitherlength()normake(). Forgetting a primitive is a compile error inmany<D>ordsrl::is_frame<D>, not an iteration that silently advances by the wrong length. This is the reason not to derive fromdsrl::frameand shadow its members.- No
protectedhelpers: a derived class that wants the default resolution calls the public building blocks, the same onesframecalls. That also serves a user frame that holds aframeas a member instead of deriving — the blocks do not care. buffer()isheader_.buffer(): the untrimmed buffer the view was constructed over, with the exact semanticsproxy::buffer()already has (not an extent of anything). No extra state; it only needsusing base::buffer;inheader. It is the bound for any scan — terminators, padding.as_span(),header_span()andpayload_span()callself.length()through deducingthis, so they follow the derived class.header_span()/payload_span()come back here (REQ-128) because they are pure derived operations.dsrl::frame<H, P>isbase_frame<H, P>plus the two default primitives (makeas above,lengthby precedence: a header length field, otherwise header + payload). Its behaviour does not change.
The vocabulary serder is the custom frame¶
A custom frame is not "a class deriving from dsrl::frame". It is a vocabulary struct, exactly like
rbe::frame, rbe::blob and rbe::many:
namespace cboe::top {
struct line { // the serder: what the user actually writes
using header_type = Header;
using payload_type = messages;
using dsrl_type = line_view; // derives from dsrl::base_frame<Header, messages::dsrl_type>
// using srl_type = line_writer; // once srl::base_frame exists
// using value_type = rbe::value_type<header_type, payload_type>;
static constexpr bool self_delimiting = true; // delimitation opt-in, see below
};
using lines = rbe::many<line>; // composes today: many only requires frame_serder
}
The mechanism already exists: serder_traits, to_dsrl_t, rbe::many<T> and frame_serder compose
over dsrl_type without knowing whether the library or the user wrote it. rbe::blob is the proof.
The view replaces step 2 and reuses steps 1 and 3 untouched:
class line_view : public rbe::dsrl::base_frame<Header, messages::dsrl_type> {
using base = rbe::dsrl::base_frame<Header, messages::dsrl_type>;
public:
static constexpr auto make(buffer_type buf) -> std::optional<line_view> {
auto const hdr = rbe::dsrl::header<Header>::make(buf); // step 1
if (not hdr) return std::nullopt;
auto const rest = buf.subspan(hdr->length());
auto const n = line_extent(rest); // step 2: the only custom part
if (not n) return std::nullopt; // incomplete line: wait for more
auto const line = rest.first(*n);
return rbe::dsrl::try_construct_payload<payload_type>(*hdr, line) // step 3
.transform([&](auto p) { return line_view {*hdr, p, hdr->length() + line.size()}; });
}
[[nodiscard]] constexpr auto length() const -> size_type { return line_length_; }
private:
// up to and including the terminating '\n', then every '\n' that follows it. The id plays no part,
// which is what lets iteration continue past a msg_type this build does not know.
static constexpr auto line_extent(buffer_type rest) -> std::optional<size_type> {
auto const nl = std::ranges::find(rest, std::byte {'\n'});
if (nl == rest.end()) return std::nullopt;
auto n = static_cast<size_type>(nl - rest.begin());
while (n < rest.size() and rest[n] == std::byte {'\n'}) ++n;
return n;
}
constexpr line_view(header_type h, payload_type p, size_type n) : base(h, p), line_length_(n) { }
size_type line_length_;
};
This supersedes the sketch currently at example/markets/cboe.hpp:988-1003, which derives from message
(the empty vocabulary type rather than message::dsrl_type), scans backwards from data() + length()
(reading at the frame boundary with no bound), and adds a line_length() accessor instead of the length()
primitive many actually calls. It also follows that the LF is framing, not a message field: the TOP message
types drop their trailing newline member, and the four that are left with no fields become rbe::empty
candidates.
OPRA is the same shape with a different step 2 — the extent follows from (msg_category, msg_indicator)
— and keeps any as its payload, because try_construct_payload<any> accepts a given extent. Administrative
reads its own first two bytes in step 2. None of them touches any.
try_construct_payload<any> narrows a known candidate's any to its wire size, like any other payload view, while
the line's extent lives in the frame's length() and payload_span(). An unknown id's any has nothing
to narrow to and spans exactly the line it was handed, instead of running to the end of the buffer:
many<line> steps over it and goes on.
Known limit: payload() is whatever step 3 built; a derived class that wants something other than an
any swaps step 3. Inheritance is static: a line_view
seen through base_frame const& has no length() at all, which is the right outcome — there is no silent
slice back to a default length.
Serialization¶
srl::frame does not exist yet. The decision is to design it with the same cut from the start: the
steps as public building blocks (write the header, reserve the payload, back-patch the length field),
srl::base_frame<H, P> with the derived operations (as_span() of what was written, …) and
srl::frame<H, P> composing the defaults. A line_writer would compose the same blocks and append the
'\n' bytes. Until then serder_traits only requires dsrl_type (srl_type is commented out in
frame_serder_concepts.hpp), so a custom frame is readable today and becomes writable when srl lands
without changing shape.
Open: the srl-side primitives, until srl::frame is designed (candidates: begin(header_value),
finish()).
Delimitation: the structural classification needs an opt-in¶
payload_extent_of<H, P> and is_self_delimiting<T> classify by the shape header_type /
payload_type (§3, §4). A
custom frame breaks that premise. line happens to be fine — any_id is already self-delimiting — but a
custom frame shaped like frame<PlainHeader, blob> and delimited by a terminator would classify as
buffer-delimited, and rbe::many would reject it.
Decided: is_self_delimiting<T> first consults an optional member of the vocabulary type,
static constexpr bool self_delimiting, and only falls back to the structure when it is absent.
explicitly_delimited_frame / dispatch_delimited_frame stay structural: they speak about the header
and about any, neither of which a custom frame changes. Without this opt-in, "write your own frame"
does not close.
value_type¶
rbe::value_type<H, P>(voidtoday, TODO) is{ H header; value_of<P> payload; }, withany<Ts...> → std::variant<Ts...>,many<F> → std::vector<value_of<F>>, a nested frame recursing,explicitly_empty → T. Open:blob → std::vector<std::byte>or a span.- Derivable for a custom frame that keeps the header-plus-payload shape: the vocabulary type aliases
it,
using value_type = rbe::value_type<header_type, payload_type>;. The'\n'padding oflineis framing, not value:srl_typeemits it, the value never carries it. - The eager conversion
dsrl_type → value_typestays generic as long as the view exposesheader()andpayload(), which is precisely whatbase_frameguarantees. This is the argument forbase_frameowningheader_+payload_rather than being an empty interface. - Only a frame that abandons the header-plus-payload shape writes its
value_typeby hand. Acceptable.
What a custom frame still cannot change¶
The inside of any — how an id selects a candidate — and the candidate list itself. Making a user view a
candidate of any is a separate step (a third form next to T and proxy<T> in the dispatcher, and a
non-total candidate_list::wire_size), the same one variable-size candidates need
(§8). It is independent of this section.
Rejected alternatives¶
| Alternative | Why not |
|---|---|
Derive from dsrl::frame and shadow make / length |
Forgetting length() iterates wrongly in silence. base_frame without primitives turns it into a compile error. |
base_frame with a protected resolve() doing the default resolution |
It only customizes length() in practice: replacing a step means copying frame's private code. Public building blocks serve inheritance and composition alike. |
The richer payload contract above (make + exact_length on every payload, payload<T>, payload_resolved) |
Solves the asymmetry and opens frame to user payloads, but puts a contract on every payload for cases a custom frame covers. Kept as an option. |
An extra span stored in frame for buffer() |
header_.buffer() already is that span, with no state. |
rbe::any_frame<H, Msgs...> |
The id is just one header field; a custom frame that needs another one reads it off header() in step 2 or 3. |
Have payload_extent_of recognise custom frames structurally |
It cannot: a custom frame's delimitation is not in its types. Hence the opt-in. |
12. Frame delimiting and the extent customization point¶
Open — written up separately. The classification of §3/§4 assumes a frame's delimiting is a
function of header_type and payload_type. Three shapes break that premise — terminator-delimited
(cboe TOP), counted (frame<HeaderWithCount, many<U>>) and composite or in-body discriminants
(OPRA) — and the custom-frame seam of §11 answers only the first, by hand.
Frame Delimiting and the Extent Customization Point collects that analysis:
the two axes it separates (mechanism, which is what §4 classifies, and cost, which is what
length() actually costs), the fifteen inconsistencies in the code today, every fork still open for
the extent customization point, and the contract (laws L1–L7) any extent rule has to satisfy. It
supersedes the following entries of Pending work, which are now forks rather than tasks: constraining
many, the self_delimiting opt-in, the explicit is_self_delimiting branches, and the checked
parse factory's result type.
Divergences from framing.md¶
framing.md predates the implementation. Known points where it no longer matches:
| framing.md | Now |
|---|---|
frame_length() accessor |
length() |
truncated(): a short or malformed frame is reported, never a precondition |
The frame is an unchecked view with documented preconditions (checked in hardened builds); validation belongs in a checked factory (§2) — still to reconcile with the "partially received buffer stays walkable" goal |
as_span() "clamped to the buffer" |
as_span() is exactly the frame: the span is narrowed at construction, and covering the frame is a precondition; length_of() serves partially received buffers |
remainder() on frame |
Not implemented; appears in the proposed parse factory and possibly on many |
many yields an overrunning element with truncated() set |
Undecided under the precondition model |
| Header length field "required only when the extent can't be determined any other way" | Formalized as payload_extent and self_delimiting_frame (§3, §4) |
[[= rbe::id_field]] locator |
[[=rbe::id]] on the header field, [[=rbe::id(value)]] on the message type |
proxy<T, S = lazy_t> reached through as_proxy(S) for strategy-generic code |
Rejected: it mixes owning and view contracts; a free rbe::field<...>(source) accessor instead (§9) |
frame::match(...) as a forwarder, and proxy<Header> as a second constructor parameter for a payload "whose meaning depends on the header" |
Customization moves to the frame, not the payload: a custom frame is a vocabulary serder whose dsrl_type derives from dsrl::base_frame (§11) |
Pending work¶
- Add
payload_extent::any_idand the explicitis_self_delimitingbranches (§4). - Implement
dsrl::anybuilt from(index, span), with id resolution as in §5. - Constrain
rbe::many/dsrl::manywithself_delimiting_frame. - Settle the unknown-id contract (§6) and the hardened-check mechanism.
- Reword the
buffer_delimited_frame@note(§4). - Checked
parsefactory with remainder; reconcile withtruncated()in framing.md. -
proxy: narrow the span at construction (data_.size() == length()); fix thedata()inconsistency withframe. - Variable-size wirable types (§8): static minimum, offset table built in the
proxyconstructor behindoffset_of(index), and a separatepayload_extentcase for variable-size payloads. - Free
rbe::field<...>(source)accessor overT,T const&andproxy<T>(§9), then strategy-parameterized benchmarks. -
srl::frame/srl::manyon top of the same classification. - Length units (words, element counts).
- Header-only messages (§10), then add
aquis::Heartbeatandopra::Controlback to theirmessages. - Lift
narrow_to_payload/construct_payload_hardenedout offrameas publicdsrl::payload_extent(hdr, rest)anddsrl::try_construct_payload<P>(hdr, bytes);dsrl::base_frame<H, P>with storage and derived operations only,dsrl::framecomposing the blocks on top of it (§11). -
using base::buffer;inheader, andbuffer()/header_span()/payload_span()onbase_framethrough deducingthis. -
self_delimitingopt-in consulted byis_self_delimiting<T>before the structural classification. -
rbe::value_type<H, P>and a generic eager conversion written againstheader()/payload(). -
srl::base_frame/srl::framewith the same primitives-versus-derived cut; then re-enablesrl_typeinserder_traits. - Rewrite
cboe::top::lineas a vocabulary serder plusline_view, replacing the sketch inexample/markets/cboe.hpp. -
dsrl/flatten.hppcallsheader(strategy)/payload(strategy), whichframeno longer has. -
frame<H, blob>::makecallsstd::span::makeon the hardened path, which is ill-formed; only the constructor works for a span payload. - Decide between the two frame-storage designs in frame-storage.md: the resolved
payload view (today) versus
header<H>plus a cached frame length; it needs the size-only length resolver this file's §2 lists as implemented and which is no longer in the code. - Fold these notes and framing.md into user-facing documentation.