Zero-copy and the residual flattens¶
Scope: this page describes the reference C++ implementation’s own copy behaviour — every
materialize / flatten call site in graph.cpp, rope.cpp, op_resolve_view.cpp,
op_resolve_walk.hpp, fwd_reply.cpp, fwd_router.cpp and the ESP-IDF WS integration, and
which of them are
structural. It is not the protocol standard; the implementation-independent ownership model is
reference 08 — views and ownership and
reference 09 — memory substrate. Its ESP-IDF / lwIP
references are integration facts about the transport seam.
Summary¶
On single-link traffic — one recv chunk becoming one ingress segment, which is what every
unfragmented TCP, UDP and WS producer emits — no payload flatten fires. rope_t::materialize
returns the sole link (core/include/libtracer/rope.hpp:219), a refcount bump, so the branch
write, the field write, the per-node parse cache, span-only delivery and COMPACT remote delivery
all resolve to reference counting rather than memcpy. Every payload flatten in the codebase is a
multi-link fallback.
Three copies are structural — they exist because of what the code must guarantee, not because a byte source is the wrong shape:
Structural copy |
Site |
Why it cannot go |
|---|---|---|
Ingress ownership |
|
A transient recv buffer cannot be borrowed by a rope that outlives the receive call |
Mutation ownership |
|
A mutated multi-link value must own a contiguous, patchable, trailer-cleared segment |
WS TX gather |
|
|
A fourth copy is bounded rather than structural: reply-route synthesis (tlv_sliced,
core/src/fwd_reply.hpp:109) emits rewritten route wires — tens of bytes, never
payload-scaled.
The 4096-byte decode arena (core/src/graph.cpp:2337) is structure storage, not a payload copy:
it backs the arena_tlv_t node array and the grammar walk stacks, whose existence is independent
of where the field bytes come from. The rope cursor is a byte source, so it does not remove the
arena; only a streaming decode does, and that relocates the bytes from stack to pool rather
than eliminating them. Net device RAM is unchanged; what drops is the scarce stack high-water.
1. Ownership invariant¶
The data plane copies each byte once, at the wire, into a refcounted owned segment; thereafter every view — routed suffix, child TLV, stored value, reply — is a refcount-bumped subview. The model itself is described implementation-independently in reference 08. What is specific to this implementation is where the mechanism lives:
Composition shares segments.
rope_t::subrope(off, len)(core/include/libtracer/rope.hpp:272) trims the covering links withview_t::subviewand refcounts exactly the segments its window touches. Segment handles clone by a relaxed increment (core/include/libtracer/segment.hpp:124-126); release is anacq_reldecrement that fires the backend’sdestroyat zero (:137-141). Fan-out to N subscribers is N increments.Decode holds structure only.
decode_intoemitsarena_tlv_tnodes whosewire/bodyarestd::spaninto the caller’s input — “the arena holds structure only, never bytes” (core/include/libtracer/tlv_arena.hpp:8-9, node type at:31). Decode allocates node bookkeeping, never payload, and is zero-copy over its input provided that input is contiguous. That contiguity constraint is what §3 and §4 turn on.Egress scatter-gathers.
rope_t::to_iovec(core/include/libtracer/rope.hpp:301) emits one span per link into the original segments. The host WS server builds[header, link0, link1, …]andsendmsgs it with “no flatten, no re-copy (server frames are UNMASKED, RFC 6455 §5.1)” (core/src/transport_ws.cpp:274); TCP prepends a u32-LE length viaprefixed_iov_t(core/src/transport_tcp.cpp:56). WithkMaxServerIov = 16(core/src/transport_ws.cpp:150), the common reply (≤ ~6 spans) fits the stackstd::array<::iovec, kMaxServerIov + 1>(core/src/transport_ws.cpp:282) — zero heap, zero payload copy. The only host TX copy is the kernel skb copy every BSD socket pays.Flatten refuses a heterogeneous rope. A DEVICE link is not CPU-addressable, so a host memcpy would fault; the one body
flattenandtry_flattenshare checksall_host()up front and refuses (core/src/rope.cpp:21). Since #917 the refusal carries its cause:try_flattenreturnsflatten_err_t::NOT_HOSThere andNO_MEMORYfor an allocator refusal, and a zero-length rope is a success carrying an empty view.flattenitself is the lossy wrapper that collapses all three back into that empty view.
2. Flatten call sites¶
Each row: the site, whether it fires on the single-link path (every unfragmented producer) or only on multi-link frames (fragmented WS, reassembled CAN), whether it is structural or a fallback, and whether the rope-cursor migration removes it. The circled numerals are row identifiers for the rest of this page.
# |
Site |
Single-link? |
Multi-link? |
Kind |
Removed by the rope cursor? |
|---|---|---|---|---|---|
① |
Ingress ownership — |
yes (it is the recv) |
yes |
Structural |
No — orthogonal; it is the ingress floor |
② |
Branch write — |
no — refcount bump |
yes (one flatten to feed the span cursor) |
Fallback |
Multi-link leg: yes, via a rope-native branch decode |
③ |
Field write — the twin of ② ( |
no — refcount bump |
yes |
Fallback |
Same as ② |
④ |
4096-byte decode arena ( |
yes — paid on every branch write |
yes |
Structure scratch, not a payload copy |
No — see §3; the rope cursor is a byte source, not a structure store |
⑤ |
|
no — a single link is still COPIED, through the same backend ( |
yes — flattens the multi-link subrope |
Structural for mutated values |
No — this step is the ownership copy; it still owns |
⑥ |
Per-node parse contiguity — |
no — a single-link node adopts |
only per straddling node |
Fallback, span-node-shaped |
Yes — rope-native node accessors remove it |
⑦ |
|
no |
yes — only when no rope sink is installed; a refused materialize now DROPS the frame rather than handing the sink an empty span (#917) |
Fallback — the cost of a span-only sink |
Yes — installing the rope sink removes it; see §4.1 |
⑧ |
WS RX reassembly — |
no — unfragmented delivers borrowed (scratch-backed, no per-frame alloc for fitting frames) |
yes — O(n²) across fragments |
Fallback |
Enables ⑦’s removal; the copy itself is a pool-recv question, not a cursor one |
⑨ |
WS TX gather — memcpy into a pooled tx work slot in |
copy per frame per peer; alloc only on the oversize arm |
yes |
Structural within the |
No — TX-side; the cursor is irrelevant |
⑩ |
COMPACT remote delivery — |
no — adopt |
yes — auto-promotion leg only |
Fallback, narrow |
Yes — a scatter-gather compact encoder |
⑪ |
Control-child strip — |
no |
only a multi-link ADVERTISE / COMPACT sub-rope |
Fallback, and fused rather than eliminated — the next consumer re-encodes anyway |
Yes, with a near-zero saving |
⑫ |
Reply-route synthesis — |
yes |
yes |
Bounded frame synthesis: the route wires are rewritten |
No — these are emitted bytes, not a copy of payload |
On the single-link path the only copies that fire are ① (the recv floor), ④ (the structure arena),
⑨ (the esp_http_server WS TX gather) and ⑫ (bounded route synthesis). ①, ⑨ and ⑫ are structural
or bounded, which leaves ④ as the one always-paid removable cost on that path — and it is not a
payload copy. Every payload flatten (②③⑤⑥⑦⑧⑩⑪) is multi-link-only. Completing the rope-cursor
migration is therefore insurance against fragmented-transport load, not a single-link win.
3. The 4096-byte decode arena¶
3.1 Two costs at one site¶
core/src/graph.cpp:2319 const std::expected<view_t, tr::view::flatten_err_t> head = // A: the flatten
core/src/graph.cpp:2320 value.try_materialize(*value_backend_);
core/src/graph.cpp:2322 head.error() == NO_MEMORY ? BACKPRESSURE : TYPE_MISMATCH // (paraphrased)
core/src/graph.cpp:2337 std::array<std::byte, 4096> stack; // B: the arena
core/src/graph.cpp:2338 mem::bump_source_t src(stack, *ctl_);
core/src/graph.cpp:2340 wire::decode_into(head->bytes(), src);
Cost A, the flatten (:2319) is zero-copy for a single-link rope — try_materialize returns
links()[0], a refcount bump (core/include/libtracer/rope.hpp:219) — and memcpys only a
multi-link rope, drawing from the injected value_backend_. An exhausted pool is refused by name:
graph.cpp:2322 reads flatten_err_t::NO_MEMORY and surfaces BACKPRESSURE rather than letting
the decoder read an empty head back as a malformed value, and a DEVICE-link value — which no retry
makes CPU-decodable — takes the TYPE_MISMATCH arm instead. Before #917 both arrived as the same
empty view and the site had to guess from head.empty() && total_length() != 0. Because ingress
values are single-link until the rope-native branch decode lands, Cost A does not fire on
single-link traffic. It is a fallback.
Cost B, the arena (graph.cpp:2337) is the std::array<std::byte, 4096> backing decode_into’s node
array (std::pmr::vector<arena_tlv_t>) plus the grammar walk stack and the open-node stack. It is
structure-only scratch, allocated on every branch write regardless of link count, on the deepest
thread — the httpd/WS receive task.
sizeof(arena_tlv_t) is 48 bytes on an LP64 host (compiled against
core/include/libtracer/tlv_arena.hpp:38-65; the two std::span members dominate at 16 bytes
each), so 4096 bytes holds roughly 85 nodes before the node vector alone exhausts the slab, and
fewer once the walk stacks take their share. On a 32-bit target both spans halve and the node is
correspondingly smaller; that figure has not been compiled here and is not asserted.
3.2 Why the rope cursor does not remove the arena¶
rope_cursor (core/include/libtracer/rope_decode.hpp:72) is a byte source. It lets the
grammar read fields off a scatter-gather rope by stitching straddling headers a byte at a time and
feeding the CRC link by link, satisfying the same Cursor concept as span_cursor. But
decode_into does not only read bytes — it stores structure: a random-accessible arena_tlv_t
array that parse_branch_node (core/src/graph.cpp:588) walks via end / first_child. That
node array is byte-source-independent. Swapping span_cursor for rope_cursor changes where field
bytes come from, not the fact that a node array and walk stacks must exist.
core/include/libtracer/rope_decode.hpp:17 states the same constraint from the decoder’s side:
SINK NOTE: this validates STRUCTURE + CRC over a rope; it does not yet materialize a rope frame into a tlv_t / arena node, because both sink node types hold a borrowed contiguous std::span that cannot name a straddling payload (ADR-0041 §2). Producing sink nodes from a rope is the ratification-gated follow-on (the rope-aware-decode sink-type proposal).
So the arena and the flatten are two moves, not one:
A rope-native node type — the ratification-gated sink type of ADR-0052, rope-aware decode sink node type: convert
arena_tlv_t.wire/bodyfromstd::spanto an offset region{u32 off, u32 len}and haveparse_branch_nodeemitframe_rope.subrope(off, len). This removes Cost A for multi-link frames. The node array still exists and still needs backing storage.A streaming decode — a separate rewrite driving
parse_branch_nodeoffgrammar::walkpost-order callbacks (open / leaf / close), never materializing the node array. Only this removes Cost B.
The rope cursor is a precondition for move 1 and irrelevant to move 2.
3.3 Stack, not heap¶
Move 2 does not eliminate the RAM; it relocates it. The node array and walk stacks must live
somewhere during decode, and taking them off stack.data() pushes those transient bytes into the
injected pool or heap. Net device RAM is unchanged. What drops is the scarce stack high-water:
a flat 4096 bytes — a std::array reserves its whole frame slot whether filled or not — off the
deep receive task.
A stack budget for that task counts four such buffers, not one. The decode arena is the only one
this document covers; the other three are transport receive and chunk scratch, each a 4096-byte
std::array — core/src/transport_tcp.cpp:261 (the backpressure drain),
core/src/transport_ws.cpp:739 (the WS client’s receive loop), and
core/src/posix_endpoint.cpp:661 — the ONE per-chunk scratch both multi-peer servers now
share, since #871 folded their duplicated poll loops into slot_server_t::service_peer (it
was two buffers, one apiece, before that). They are not decode arenas and carry no structure,
but they occupy the same frames and none of the four has a measured per-task high-water.
That receive task is the binding constraint on a single-core, RAM-constrained node. In the ESP-IDF
WS integration, servicing a graph request in-call was measured overflowing an 8 KB stack and needing
~12 KB on the raw WS receive thread, against the 4 KB esp_http_server default; the integration
therefore sizes the task at kRequiredHttpdStack = 12288
(integrations/esp-idf/libtracer/include/libtracer_esp/httpd_ws_link.hpp:193 — a PUBLIC constant
since #955, because only the port-binding ctor can apply it and an adopting embedder must size the
task itself; the 8 KB the deep path was measured overflowing is the other half of the same
measurement, at integrations/esp-idf/libtracer/httpd_ws_link.cpp:131, and the 4 KB platform
default is named at
integrations/esp-idf/libtracer/include/libtracer_esp/httpd_ws_link.hpp:52). Against a 4096-byte
default the arena is a full half of the frame, and it is the single largest consumer on the write
path. A 4 KB stack-high-water reclaim is the real saving even though total RAM is flat.
3.4 Arena exhaustion¶
The overflow leg does not draw from a throwing upstream. core/src/graph.cpp:2337-2338 reads
std::array<std::byte, 4096> stack;
mem::bump_source_t src(stack, *ctl_);
bump_source_t (core/include/libtracer/mem_source.hpp:277) carves from that stack buffer and,
past it, falls back to the graph’s injected control seam ctl_
(core/include/libtracer/graph.hpp:503, control_source()), whose default is the NOTHROW heap
source. Capability is unchanged — a branch tree larger than the slab still decodes — and
exhaustion is a value, not an abort: the write soft-fails as TYPE_MISMATCH
(core/src/graph.cpp:2341), which is not BACKPRESSURE — this decode cannot distinguish “the
value did not parse” from “the arena ran out” and does not try, so the block seam’s reject belongs
to the operation rather than to the seam. A bounded node that injects its own control source gets
the arena overflow drawn from that store too. No node-counting pre-pass exists, and none is
needed (#477, #588).
Streaming the decode (§3.2 move 2) changes where the walk stack is drawn from, not whether exhaustion is representable. The general failable-allocation contract is failable allocation and backpressure.
4. The rope cursor¶
4.1 Rope-native consumers¶
rope_cursor drives four live consumers with no flatten:
check_frameandvalidate_rope(core/src/rope_decode.cpp:32,:46) validate structure and CRC straight over a rope.The lazy
tlv_view_ttier walks children one header at a time off a refcounted subrope.on_control_rope/peek_control(core/src/fwd_router.cpp:2561,:2512) read a control frame’s label off the rope and materialize only the sub-rope a re-encoding consumer needs contiguous (:2567,:2545) — out of the router’s injectedflatbackend, and a refused flatten drops the frame rather than delivering an empty value (#730).The FWD request terminus:
resolve_terminus_rope(core/include/libtracer/fwd_router.hpp:1392-1400) adopts a fragmented request astlv_view_t::over(rope)and resolves it throughop_resolver_t::resolve(tlv_view_t).
The forward hop scatter-gathers a multi-link frame over the rope cursor with no flatten; the egress
gathers each region’s per-link sub-spans into a block_array_t drawn from the injected rx_, and
exhaustion drops the frame rather than throwing (core/include/libtracer/fwd_router.hpp:1451-1459,
#596).
Two limits on that tier are load-bearing. First, a single-link rope never reaches
resolve_terminus_rope — on_frame_rope_impl short-circuits it deliberately into the
single-link view path (core/src/fwd_router.cpp:1919, the check at :1924-1929). Second, the tier earns its place only on large, lightly
fragmented frames: at 64 KB across 2 links it is ~12% ahead of flatten-then-arena, and behind it
everywhere smaller (core/include/libtracer/fwd_router.hpp:1365-1368, recorded as an erratum to
ADR-0053, lazy rope-backed decode view).
That figure carries no host, sample count or spread in the source that records it, so it is a
direction, not a budget.
Row ⑦ has changed shape rather than disappearing. The span fallback in
receiver_slot_t::deliver_rope (core/include/libtracer/receiver_slot.hpp:143) still exists for a
slot with no rope sink installed, but the router does not flatten on the reply path: a REPLY that
reaches its originator is handed to the sink rope-native
(core/src/fwd_router.cpp:1987-1991). The contract at
core/include/libtracer/fwd_router.hpp:660-664 states it — the router performs no decode and no
flatten, a rope-delivered reply reaches the sink zero-copy, a sink that wants contiguous bytes
holds const view_t m = reply.materialize(), and only a multi-link reply pays one flatten, on
demand. The escape hatch is the consumer’s, not the router’s.
4.2 Sites the rope cursor does not reach¶
Rope-native branch and field decode (§3.2 move 1) — point the branch and field write paths at a rope-aware node type instead of
decode_into+materialize. Removes ②③.Streaming branch decode (§3.2 move 2) — removes ④’s on-stack arena.
Rope-native node accessors for the walk —
ensure_cache(core/src/op_resolve_view.cpp:253-259) flattens each accessed node whose own subrope straddles a link. Convertingwire()/body()fromstd::spanto rope-native readers (fields viaload_le/for_each_span) plus a scatter-gather reply head removes ⑥.
None of these reaches ⑤. own_wire (core/src/op_resolve_view.cpp:141) is structural: a mutated
multi-link value must own a contiguous, patchable, trailer-cleared segment
(ADR-0041, terminus arena decode span contract
§2), so it flattens once. The zero-copy pin_wire subrope applies only to the opt-in verbatim
referenced STORE, not to ops that patch bytes.
4.3 Complexity of a rope walk¶
span_cursor::byte_at is O(1). rope_cursor::byte_at calls locate(), a linear scan over links,
so load_le(n) is n such scans, a header read costs O(header_bytes × L) and a CRC feed costs
O(payload + L), for L links.
For a single-link rope — the common case, one recv chunk becoming one ingress segment, where
materializereturnslinks()[0]— L = 1, sorope_cursor≈span_cursorplus a trivial constant. There is no regression.For a multi-link rope the header pointer-chase is bounded by fragment count × a 4–6-byte header, and the payload feed is asymptotically identical to the memcpy it replaces, but read-only and without allocating a destination.
Against the alternative it removes —
flatten()is O(payload) read and write, plus a pool or stack allocation — the rope walk wins on latency (no write pass, no allocation) and on RAM (no destination buffer).
There is no latency-versus-RAM trade-off for the decode walk. The one genuine cost is code size:
rope_cursor lives in a separate translation unit so a span-only target never instantiates it
(ADR-0048, one wire grammar, chunk cursor, rope-aware decode
§1). That is a build-configuration trait, not a runtime trade-off.
5. The ingress copy¶
The ingress ownership copy (①) is structural as a copy, but it is not always an extra copy.
Why it must own. vertex_t::store refcounts rather than copies, so durability rests on stored
segments being long-lived. The receive buffer is transient: the ESP-IDF WS link reads a fitting
frame into a once-per-link reusable scratch, falling back to an exact-size
new (std::nothrow) std::byte[frame.len] only for oversized frames
(httpd_ws_link_t::on_data_frame), and either buffer is reused or dies when the receive
call returns, while the rope outlives it — pinned in a last-known-value slot, fanned out
asynchronously, awaited. A borrowed view of that buffer would dangle. Ingress must therefore land
bytes in an owned segment.
Why the pull path pays nothing extra. The TCP serve loop reads the body straight into the
accepted segment: read_exact(fd, seg->bytes.data(), len) (core/src/transport_tcp.cpp:304,
read_exact defined at :241) fills a segment freshly allocated from the injected backend by
length_prefix_framer::on_prefix. The pooled receive target is the owned segment — one kernel
copy and zero user-space copies. The in-source rationale names the trade explicitly: feeding recv
chunks through feed() “would add one” copy, so the pull loop shares framing rules with the
chunk-fed transports rather than their state machine (core/src/transport_tcp.cpp:278-283). The
only stack scratch left on this path is drain()’s 4096-byte backpressure discard buffer
(core/src/transport_tcp.cpp:261), which runs when a frame is dropped, not when one is delivered.
Where the pull-path shape is not followed, the residual costs are pool-recv questions, not flatten questions:
The ESP-IDF WS link delivers borrowed (
httpd_ws_link_t::deliver), forcing a downstream ownership copy at store. The per-framenew[]itself is gone for fitting frames (they read into the once-per-link scratch); the residual is the shape: landing the payload in a bounded rx pool, adopting it and delivering owning — the TCP/UDP shape — would, by feeding the rope tier an owned segment, let the branch and field decode collapse to refcount bumps once the sink is rope-native.WS reassembly (⑧) regrows exact-size per fragment (
integrations/esp-idf/libtracer/httpd_ws_link.cpp:631-641), which is O(n²) in total bytes copied. Chaining each fragment as an owning rope link makes it O(n) owning copies — the CAN model, which is what the hosttransport_ws.cppdoes.
The DEVICE-link constraint never conflicts with pool-recv. Receive targets are always host
memory, so a host pool slot is a legal recv-and-adopt target; only a CPU-side flatten of a DEVICE
link would fault, and flatten refuses that up front (core/src/rope.cpp:21) — as
flatten_err_t::NOT_HOST, a verdict no retry clears, kept distinct from the allocator’s.
Removing even the ownership copy requires LWIP_NETCONN plus a pbuf-wrapping mem_backend
(destroy = pbuf_free, the pbuf is the rope link). That collapses ①, but it is non-portable — no
pbuf on a Linux host — and incompatible with esp_http_server’s WS framing. For a BSD-socket API,
① stays.
Every pool-recv change shares one precondition, and it is built. A segment
self-routes reclaim on whichever subscriber thread drops the last reference, concurrent with a
writer’s alloc, so the receive backend must be thread-safe. synchronized_pool_t<Sync>
(core/include/libtracer/mem_pool.hpp:182) is that backend, with the critical section as a
compile-time policy
(ADR-0060, LKV copy store and injected value backend
§2, selected per target as a module-set trait
(ADR-0047, build-time closed module sets
§2): a host keeps the spinlock sync_pool_t, and a single-core priority-preemptive target — where
a lower-priority slot-holder cannot run while a higher-priority task spins — takes the
interrupt-disable tr::esp::critical_pool_t from the ESP-IDF component
(integrations/esp-idf/libtracer/include/libtracer_esp/critical_pool.hpp; it needs FreeRTOS
headers, so it is not in core/). It is opt-in construction: no seam defaults to a pool.
6. Removal candidates, ranked by value¶
Ranked by value on single-link traffic for a single-core, RAM-constrained node, with the saving and the gate.
Rank |
Change |
Removes |
Saving |
Fires on the single-link path? |
Gate |
|---|---|---|---|---|---|
1 |
Streaming branch decode (walk-callback driven, no node array) |
④, the on-stack 4096-byte arena |
~4 KB stack high-water off the deepest task, the binding constraint. RAM relocates stack→pool rather than disappearing. |
yes — always paid |
Latency: the walk stack becomes pool- or heap-drawn |
2 |
WS receive into a pooled segment, deliver owning |
⑧’s per-frame |
One allocation per WS frame per session removed, and the rope tier gets an owned segment |
yes — every WS frame |
Changes the WS delivery contract; the thread-safe pool it draws from already exists (§5) |
3 |
Rope-native branch and field node type |
②③, the multi-link branch/field flatten |
Nothing on single-link, which is a refcount bump; one flatten becomes a refcount bump on fragmented POINT writes |
no — multi-link only |
Ratification-gated sink type (ADR-0041 §2); needs rank 1 to matter |
4 |
Rope-native walk accessors plus a scatter-gather reply head |
⑥, the per-straddling-node |
On straddling route TLVs, fuses flatten and memcpy into one gather |
no — multi-link only |
Converts the shared span-based |
5 |
Rope-chaining WS reassembler (the host |
⑧’s O(n²) regrow |
O(n²) → O(n) owning copies; no 2×n transient heap peak. Not zero-copy, and fragmentation may worsen (k small segments). |
no — fragmented only |
Needs rank 2 or the rope sink wired, or ⑦ re-materializes |
6 |
Scatter-gather COMPACT encoder |
⑩’s delivery flatten |
Real only on server-side |
no |
Low value |
— |
|
①, the ingress ownership copy |
Collapses even the structural copy |
yes |
Non-portable (no pbuf on a Linux host); incompatible with |
— |
Raw |
⑨, the TX gather |
One full-payload allocation and memcpy per frame per peer |
yes |
Abandons the threadless HTTP-server seam; breaks the |
Structural copies¶
① the ingress ownership copy — a transient receive buffer cannot be borrowed by a rope that outlives it. On the pull path it is the recv: one kernel copy, zero user-space copies. Removable only by the non-portable
LWIP_NETCONNroute.⑤
own_wire— a mutated multi-link value must own a patchable contiguous segment (ADR-0041 §2). This step is the ownership copy, not its removal.⑨ the WS TX gather —
httpd_ws_send_frame_asynctakes one contiguous{payload, len}andhttpd_queue_workis asynchronous, so the rope links are gone before the send runs; the reply must be flattened into one owned buffer.httpd_ws_link_t::queue_sendgathers once, straight into a pre-allocated tx work slot claimed lock-free (nothrow heap only for an oversize payload; an exhausted pool drops and counts, #949), and the directed reply path hands it the reply rope’s iovec with no intermediate flatten temporary. One irreducible gather-copy remains — now allocation-free in steady state. It is the price of threadlessness — riding the existing HTTP-server task, adding no FreeRTOS task — and is removable only by leaving the seam. The rope cursor is irrelevant to it: the sink is a send API, not a decoder.⑫ reply-route synthesis —
tlv_sliced(core/src/fwd_reply.hpp:109) emits rewritten route wires. Bounded at tens of bytes, never payload-scaled.