Zero-copy data flow
Vox aims to minimize copies at every layer. This document specifies how payloads flow through the system — from user code, through serialization and transport, to deserialization on the other side — and which link types enable zero-copy at each stage.
Backing storage
A Backing is an owned handle that keeps a region of bytes alive. The
receive path deserializes borrowing from a Backing, producing values that
may contain &str or &[u8] pointing into the original buffer.
Boxed — a heap-allocated Box<[u8]>. Used by stream (TCP) and
WebSocket links after reading into an owned buffer.
BipBuf (copy-out) — for small messages that fit inline in the
BipBuffer ring, the receiver copies the payload bytes into a
heap-allocated Box<[u8]> and immediately releases the ring region.
This is intentional: the BipBuffer consumer must release regions in
FIFO order, so holding a borrow would block all subsequent receives
until that particular message is dropped. Copying out allows multiple
in-flight messages to be processed concurrently.
A future optimization is to copy into buffers drawn from a pool rather
than allocating a fresh Box<[u8]> per message, amortizing allocation
cost for small inline messages.
VarSlot — a slot in a shared-memory VarSlotPool. For medium messages, the sender writes into a variable-size slot and the receiver borrows from it. The slot is returned to the pool when the Backing is dropped.
Mmap — a memory-mapped region for large payloads. The sender writes a file (or anonymous mapping), the receiver maps it and borrows from the mapping.
Send path
On the send side, user code provides a value to serialize. The value may borrow from the calling context.
Borrowed arguments
A call like client.method(&buf[..12]).await passes a borrowed slice.
The future returned by method() captures the reference. The caller's
.await keeps the borrow alive for the future's entire lifetime —
including across yield points (e.g. waiting for outbound queue capacity).
A call like client.method(Context { name: &my_string }).await passes a
struct that borrows from the calling scope. This is valid for the same
reason: the future captures the struct, which holds the borrow, and the
caller's .await keeps everything alive until the future completes.
The send future is not 'static — it borrows from the caller's scope.
This means it cannot be spawned on executors that require 'static
futures (e.g. tokio::spawn). It is driven to
completion by the caller's .await, which guarantees all borrows remain
valid. The actual sequence is:
- The future synchronously serializes the borrowed value into an owned prepared buffer
- The future awaits enqueue/send of that prepared buffer
Serialization happens before the backpressure yield. The borrowed data is valid because the caller is still awaiting.
Link-specific send behavior
Stream links (TCP): serialize into a write buffer, flush to socket. One copy (value → write buffer).
WebSocket links: serialize into a message buffer, send as a WebSocket frame. One copy (value → message buffer).
In-process links (WASM ↔ JS): serialize into a reusable message buffer,
deliver to JS via a js_sys::Function callback as a Uint8Array. One copy
(value → message buffer).
Receive path
On the receive side, LinkRx::recv returns a Backing that keeps raw
bytes alive. For heap-backed links the Backing owns the buffer. The conduit
deserializes borrowing from this backing, producing a SelfRef<T> that
pairs the decoded value with its backing.
SelfRef<T> guarantees correct drop order: the decoded value is dropped
before its backing storage. This allows the value to contain references
(&str, &[u8]) pointing into the backing without use-after-free.
Link-specific receive behavior
Stream links (TCP): recv reads a length-prefixed frame into a
Box<[u8]>. One copy (socket → heap). Deserialization borrows from the
box.
WebSocket links: recv receives a complete message as bytes::Bytes,
converted to Box<[u8]>. One copy at the Vox link boundary (transport-
internal buffering is not counted). Deserialization borrows from the box.
In-process links (WASM ↔ JS): recv yields a Vec<u8> pushed by JS
via the deliver() method, converted to Box<[u8]>. One copy (JS
Uint8Array → Rust heap). Deserialization borrows from the box.
Framing layers
A message passes through three layers of framing between user code and the physical wire. Each layer has a distinct responsibility.
Layer 1: Value encoding
The user's Rust value is serialized using postcard (via facet-postcard). Postcard produces a compact binary encoding that supports zero-copy deserialization — string and byte slice fields can borrow directly from the input buffer.
The output of this layer is a contiguous byte sequence representing the serialized value.
For Message<'payload> payload fields marked as opaque, value encoding is
the boundary where erased payload behavior is applied:
- Outgoing (
Message<'call>): the opaque adapter maps the payload to(PtrConst, Shape, Option<TypePlanCore>), and postcard serializes that mapped value. - Incoming (
Message<'static>insideSelfRef): postcard decodes the payload byte sequence and materializes deferred payload state as either a borrowed byte slice (when input backing is stable) or owned bytes.
Conduit framing and link framing do not change this mapping contract; they only add/remove their own framing around the same encoded payload bytes.
Opaque values are length-prefixed with a fixed 4-byte little-endian u32, not a varint. This allows the serializer to reserve the prefix bytes, serialize the mapped value directly into the output buffer, and patch the length afterward — avoiding a temporary allocation to measure the size. The deserializer reads a u32le to determine how many bytes to consume (or skip, for unknown fields).
Layer 2: Conduit framing
The conduit wraps the serialized value bytes depending on the conduit type:
BareConduit — no additional framing. The serialized value bytes are passed directly to the link. Suitable for transports where reliability is inherent or unnecessary (in-process, memory).
StableConduit — serializes a Frame<T> instead of a bare T. The
Frame struct contains:
seq: u32— monotonically increasing sequence numberack: Option<u32>— highest sequence number received from the peeritem: T— the actual value
The entire Frame<T> is serialized in one postcard pass — there is no
separate header serialization step. The conduit framing fields are just
the first fields of the serialized output. The conduit maintains a
replay buffer of serialized frame bytes for retransmission after
reconnection. Required for transports that may drop the underlying
connection (TCP, WebSocket).
Layer 3: Link framing
The link adds transport-specific framing to preserve message boundaries:
Stream links (TCP, Unix sockets): 4-byte little-endian length prefix
followed by the payload bytes: [len: u32 LE][payload].
WebSocket links: each message is sent as a single binary WebSocket frame. The WebSocket protocol preserves message boundaries natively.
In-process links (WASM ↔ JS): no framing. Messages are Vec<u8>
passed through a js_sys::Function callback (Rust → JS) and an MPSC
channel (JS → Rust). Used for same-tab WASM ↔ TypeScript communication.
Memory links (in-process): no framing. Messages are Vec<u8> passed
through an MPSC channel. Used for testing and in-process communication.
Framing combinations
Not all conduit × link combinations are valid or useful:
| Conduit | Stream | WebSocket | InProcess | SHM | Memory |
|---|---|---|---|---|---|
| BareConduit | — | — | yes | yes | yes |
| StableConduit | yes | yes | — | — | — |
BareConduit is used with links that don't lose connections (SHM, memory, InProcess). StableConduit is used with links that may disconnect (TCP, WebSocket) and need seq/ack for replay on reconnect.
End-to-end pipeline and lifetimes
The runtime pipeline is:
- Link layer receives/sends framed transport bytes.
- Conduit layer removes/applies conduit framing (
TvsFrame<T>). - Value layer decodes/encodes
Message<'payload>fields, including opaque payload handling.
Incoming path:
LinkRx::recvyieldsBackingcontaining one message payload.- Conduit deframes and deserializes into
SelfRef<Message<'static>>. - Driver/dispatch reads
method_id, resolves concrete args shape/plan, and mapsSelfRef<Message<'static>>toSelfRef<ConcreteArgs>using the same backing.
Outgoing path:
- Driver builds
Message<'call>borrowing from call scope as needed. - Opaque payload mapping happens during value serialization.
- Conduit applies its framing (
TorFrame<T>), then link applies transport framing at commit/send time.
Serialization timing
Despite the three logical layers, serialization of the payload happens
exactly once.
The conduit is generic over the value type T, so:
- BareConduit serializes
Tinto one owned outbound buffer. - StableConduit serializes
Frame<T>into one owned outbound buffer.
In both cases, postcard writes the output into the prepared buffer owned by the conduit. There is no intermediate re-serialization between layers — value encoding and conduit framing are a single serialization pass, and the link applies transport framing (length prefix, WebSocket frame boundary, etc.) when sending those bytes.
The conduit MUST NOT serialize the value into a temporary buffer and
then re-serialize it into another buffer. The conduit serializes the value
(or Frame<T>) into its prepared outbound buffer in one pass.
Scatter/gather serialization
Serializing into a prepared outbound buffer requires knowing the total encoded size before allocating that buffer. Postcard's encoding is sequential and deterministic, so the serializer can compute the exact output size and collect copy instructions without writing to a final destination buffer.
The serializer performs a single walk over the value and produces a scatter plan: a staging buffer plus an ordered list of segments. Each segment is either:
- Staged — a byte range within the staging buffer (structural bytes: varints, enum tags, length prefixes, fixed-size fields), or
- Reference — a pointer and length into the original value's memory
(blob fields:
&[u8],&str).
The staging buffer contains only the structural bytes. Blob payloads are never copied into it — they remain at their original addresses.
The total encoded size is the sum of all segment lengths (staged + referenced). This is known after the walk completes, before any bytes are written to the destination.
To write the scatter plan into a destination buffer:
- Allocate an owned buffer of
total_size. - Walk the segment list in order. For each segment,
memcpyits bytes (from staging buffer or from the referenced source) into the buffer at the current offset. - Enqueue/send that buffer through the link.
This is the only point where bytes are copied into the prepared outbound buffer. Blob data goes directly from the caller's memory to that buffer — one copy total.
The scatter plan borrows from the original value. The plan MUST be
consumed (written into a slot) before the borrows expire. In practice,
the conduit's send method builds the plan and writes it within the
same call, while the caller's .await keeps all borrows alive (see
zerocopy.send.lifetime).
For StableConduit, the replay buffer needs an owned copy of the
serialized frame bytes. After writing the scatter plan into the write
slot, the conduit copies the slot's byte range into the replay buffer.
This is one additional memcpy (slot → replay buffer) that is
unavoidable for reliability — but there is no intermediate Vec
between serialization and the write slot.
Payload representation
Payload represents a value ready for serialization. Its variants
reflect the different ownership situations:
Borrowed — a type-erased pointer to caller-owned memory (stack, heap, arena, etc.) plus its Shape. Used on the send path when the value is reachable for the borrow lifetime.
Bytes — a contiguous byte buffer that is already serialized (e.g. when forwarding a message without deserializing, or when the link provides raw bytes). Paired with a Backing to keep the buffer alive.
Copy count summary
Copy counts are measured at the Vox link boundary — copies internal to the transport library (e.g. TLS decryption, WebSocket frame assembly) are not included.
| Direction | Stream (TCP) | WebSocket | SHM (inline) | SHM (slot-ref) | SHM (mmap) |
|---|---|---|---|---|---|
| Send | 1 | 1 | 1 | 0 | 0 |
| Receive | 1 | 1 | 1 | 0 | 0 |