Zero-copy data flow

zerocopy

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

zerocopy.backing

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.

zerocopy.backing.boxed

Boxed — a heap-allocated Box<[u8]>. Used by stream (TCP) and WebSocket links after reading into an owned buffer.

zerocopy.backing.bipbuf

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.

zerocopy.backing.bipbuf.pool

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.

zerocopy.backing.varslot

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.

zerocopy.backing.mmap

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

zerocopy.send

On the send side, user code provides a value to serialize. The value may borrow from the calling context.

Borrowed arguments

zerocopy.send.borrowed

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

zerocopy.send.borrowed-in-struct

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.

zerocopy.send.lifetime

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:

  1. The future synchronously serializes the borrowed value into an owned prepared buffer
  2. 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.

zerocopy.send.stream

Stream links (TCP): serialize into a write buffer, flush to socket. One copy (value → write buffer).

zerocopy.send.websocket

WebSocket links: serialize into a message buffer, send as a WebSocket frame. One copy (value → message buffer).

zerocopy.send.inprocess

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

zerocopy.recv

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.

zerocopy.recv.selfref

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.

zerocopy.recv.stream

Stream links (TCP): recv reads a length-prefixed frame into a Box<[u8]>. One copy (socket → heap). Deserialization borrows from the box.

zerocopy.recv.websocket

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.

zerocopy.recv.inprocess

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

zerocopy.framing

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

zerocopy.framing.value

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.

zerocopy.framing.value.opaque

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> inside SelfRef): 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.

zerocopy.framing.value.opaque.length-prefix

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

zerocopy.framing.conduit

The conduit wraps the serialized value bytes depending on the conduit type:

zerocopy.framing.conduit.bare

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

zerocopy.framing.conduit.stable

StableConduit — serializes a Frame<T> instead of a bare T. The Frame struct contains:

  • seq: u32 — monotonically increasing sequence number
  • ack: Option<u32> — highest sequence number received from the peer
  • item: 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).

zerocopy.framing.link.stream

Stream links (TCP, Unix sockets): 4-byte little-endian length prefix followed by the payload bytes: [len: u32 LE][payload].

zerocopy.framing.link.websocket

WebSocket links: each message is sent as a single binary WebSocket frame. The WebSocket protocol preserves message boundaries natively.

zerocopy.framing.link.inprocess

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.

zerocopy.framing.link.memory

Memory links (in-process): no framing. Messages are Vec<u8> passed through an MPSC channel. Used for testing and in-process communication.

Framing combinations

zerocopy.framing.combinations

Not all conduit × link combinations are valid or useful:

ConduitStreamWebSocketInProcessSHMMemory
BareConduityesyesyes
StableConduityesyes

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

zerocopy.framing.pipeline

The runtime pipeline is:

  1. Link layer receives/sends framed transport bytes.
  2. Conduit layer removes/applies conduit framing (T vs Frame<T>).
  3. Value layer decodes/encodes Message<'payload> fields, including opaque payload handling.
zerocopy.framing.pipeline.incoming

Incoming path:

  1. LinkRx::recv yields Backing containing one message payload.
  2. Conduit deframes and deserializes into SelfRef<Message<'static>>.
  3. Driver/dispatch reads method_id, resolves concrete args shape/plan, and maps SelfRef<Message<'static>> to SelfRef<ConcreteArgs> using the same backing.
zerocopy.framing.pipeline.outgoing

Outgoing path:

  1. Driver builds Message<'call> borrowing from call scope as needed.
  2. Opaque payload mapping happens during value serialization.
  3. Conduit applies its framing (T or Frame<T>), then link applies transport framing at commit/send time.

Serialization timing

zerocopy.framing.single-pass

Despite the three logical layers, serialization of the payload happens exactly once. The conduit is generic over the value type T, so:

  • BareConduit serializes T into 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.

zerocopy.framing.no-double-serialize

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

zerocopy.scatter

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.

zerocopy.scatter.plan

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.

zerocopy.scatter.plan.size

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.

zerocopy.scatter.write

To write the scatter plan into a destination buffer:

  1. Allocate an owned buffer of total_size.
  2. Walk the segment list in order. For each segment, memcpy its bytes (from staging buffer or from the referenced source) into the buffer at the current offset.
  3. 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.

zerocopy.scatter.lifetime

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

zerocopy.scatter.replay

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

zerocopy.payload

Payload represents a value ready for serialization. Its variants reflect the different ownership situations:

zerocopy.payload.borrowed

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.

zerocopy.payload.bytes

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

zerocopy.copies

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.

DirectionStream (TCP)WebSocketSHM (inline)SHM (slot-ref)SHM (mmap)
Send11100
Receive11100