RPC Concepts
The RPC layer sits on top of connections. It defines how requests are made, how responses are returned, and how data flows over channels.
Transparent retry is specified separately in Retry. The RPC layer defines the request/response/channel model for a single attempt; the retry layer defines when multiple attempts address the same logical operation.
A service is a set of methods. In Rust, a service is defined as a trait
annotated with #[vox::service]. Methods only take &self — a service
does not carry mutable state. Any state must be managed externally
(e.g. behind an Arc<Mutex<_>> or similar).
Each method in a service is an async function. Its arguments and return
type must implement Facet. The #[vox::service] macro generates a
{ServiceName} trait. Method shape depends on whether the success return
type borrows with explicit 'vox:
- Owned success return: method returns the value (
TorResult<T, E>) - Borrowed
'voxsuccess return: method receivescall: impl vox::Call<Ok, Err>and replies explicitly
This preserves borrowed-reply support while keeping owned-return handlers ergonomic.
Every method has a unique 64-bit identifier derived from its service name
and method name (see r[schema.method-id]). The signature is not included —
schema exchange handles type evolution. This is what gets sent on the wire
in Request messages.
The method ID ensures that different services can have methods with the same name without collision.
The exact algorithm for computing method IDs is defined in the signature specification. Other language implementations receive pre-computed method IDs from code generation.
Adding new methods to a service is always safe — peers that don't know about a method simply report it as unknown.
Renaming a service or method is a breaking change (the method ID changes).
Changing argument types, return types, or the structure of types used in method signatures may or may not be breaking, depending on whether the schema translation layer can bridge the difference (see the schema exchange specification for details on translation plans and compatibility rules).
Each connection is bound to exactly one service. If a peer needs to talk multiple protocols, it opens additional virtual connections — one per service.
A handler handles incoming requests on a connection. It is a user-provided implementation of a service trait. The vox runtime takes care of deserializing arguments, routing to the right method, and sending back responses.
A caller makes outgoing requests on a connection. It is a generated struct
(e.g. AdderClient) that provides async methods matching the source trait
signature, and takes care of serialization and response handling internally.
Successful responses include both return data and response metadata.
Runtime caller handles for a given connection MUST share a refcounted liveness guard. Cloning a caller increments that refcount; dropping a caller decrements it. A connection is considered caller-live while this refcount is greater than zero.
When the caller liveness refcount for a non-root connection reaches zero, the
runtime MUST close that connection as if the local peer requested a graceful
close. This close operation MUST be automatic and MUST NOT require the user to
pass the ConnectionId manually.
When the caller liveness refcount for the root connection reaches zero, the
runtime MUST mark the root connection as internally closed. It MUST NOT send a
protocol CloseConnection message for connection ID 0.
Once the root is internally closed, the session MUST be torn down when and only when there are no live virtual connections left.
When establishing a session, the user provides a handler for the root
connection and starts a driver for that connection. Generated clients are
then built from driver.caller().
In code, this looks like:
let ( mut session, handle, _session_handle) = vox_core:: session:: initiator ( conduit)
. establish ()
. await ?;
let dispatcher = AdderDispatcher :: new ( my_adder_handler);
let mut driver = vox_core:: Driver :: new ( handle, dispatcher, vox_types:: Parity :: Odd );
let client = AdderClient :: new ( driver. caller ());
let response = client. add ( 3 , 5 ). await ?;
let result = response. ret ; session.run() and driver.run() must run concurrently for calls to flow.
When a virtual connection is opened by the counterpart, the accepting peer receives the connection metadata, decides whether to accept it, and receives a connection handle in its acceptance callback. A generated client for that connection is created from that connection's driver caller.
A peer may open a virtual connection on an existing session via
SessionHandle::open_connection(...), receiving a connection handle when the
counterpart accepts it.
In Rust, virtual connections are independent driver/caller contexts:
let vconn_handle = session_handle
. open_connection (
vox_types:: ConnectionSettings {
parity : vox_types:: Parity :: Odd ,
max_concurrent_requests : 64 ,
},
vec! [],
)
. await ?;
let mut vconn_driver = vox_core:: Driver :: new ( vconn_handle, vconn_dispatcher, vox_types:: Parity :: Odd );
let vconn_client = MyServiceClient :: new ( vconn_driver. caller ()); Inbound virtual connections are accepted only when .on_connection(...) is
registered on the session builder; otherwise they are rejected.
Requests and responses
A Request message carries:
- A request ID, unique within the connection, allocated by the caller using the connection's parity
- A method ID (see
r[rpc.method-id]) - Serialized arguments
- A list of channel IDs for channels that appear in the arguments, allocated by the caller
- Metadata (key-value pairs for tracing, auth, deadlines, etc.)
When retry support is active, request metadata also carries the operation identity described in Retry.
A Response message carries:
- The request ID of the request being responded to
- The serialized return value
- Metadata
Request IDs are allocated by the caller using the connection's parity.
Sending a Request with an ID that does not match the caller's parity,
or reusing an ID that is still in flight, is a protocol error.
Every request MUST receive exactly one response. Sending a second response for the same request ID is a protocol error.
If a handler receives a request with a method ID it does not recognize, it MUST send an error response indicating the method is unknown. This is a call-level error, not a protocol error — the connection remains open.
Fallible methods
A service method may return T (infallible) or Result<T, E> (fallible),
where both T and E implement Facet.
On the Rust caller side, generated client methods return Result<_, VoxError<E>>
and do not expose response metadata:
- Infallible
fn foo() -> Tbecomesfn foo() -> Result<R, VoxError> - Fallible
fn foo() -> Result<T, E>becomesfn foo() -> Result<R, VoxError<E>>
Where R depends on whether return payload T borrows from response bytes:
- If
Tuses explicit'voxborrows,R = SelfRef<T> - Otherwise,
R = T
Borrowed return payloads MUST use explicit 'vox. Other lifetimes in return
payloads are rejected by the Rust service macro.
For Result<T, E>, E MUST be owned (no lifetimes) in Rust generated clients.
VoxError<E> distinguishes application errors from protocol-level errors:
User(E)— the handler ran and returned an application errorUnknownMethod— no handler recognized the method IDInvalidPayload— the arguments could not be deserializedCancelled— the call was cancelled before completionIndeterminate— recovery completed, but the runtime could not safely continue, replay, or re-execute the logical operation
VoxError variants differ in retryability:
- Retryable — the failure is transient; retrying the same operation on a
fresh connection may succeed:
ConnectionClosed,SessionShutdown,SendFailed - Non-retryable — the failure is permanent; retrying the same operation
against the same peer will reproduce the same outcome:
User,UnknownMethod,InvalidPayload,Cancelled,Indeterminate
Callers that loop on error MUST NOT retry non-retryable failures. In
particular, retrying a call that failed with InvalidPayload due to a schema
translation error will always produce the same failure, because the remote
peer's schema does not change without a reconnect.
Call errors affect only that call. The connection remains open and other in-flight requests are unaffected.
Channels
A channel is a unidirectional, ordered sequence of typed values between
two peers. At the type level, Tx<T, N> and Rx<T, N> indicate direction
and initial credit. T is the element type; N is a usize const generic
specifying how many items the sender may send before receiving explicit
credit (see r[rpc.flow-control.credit.initial]). Each channel has exactly
one sender and one receiver.
Tx<T, N> means "I send" and Rx<T, N> means "I receive", where "I" is
whoever holds the handle. Position determines who holds it:
- In arg position (handler holds):
Tx<T, N>= handler sends → caller,Rx<T, N>= handler receives ← caller.
Tx<T, N> and Rx<T, N> may appear in argument types of service methods.
They MUST NOT appear in method return types or in the error variant of a
Result return type.
Tx<T, N> and Rx<T, N> MUST NOT appear inside collections (lists,
arrays, maps, sets). They may be nested arbitrarily deep inside structs
and enums.
Channel IDs are allocated using the connection's parity. The caller allocates IDs for channels that appear in the request arguments.
Channels are created as part of a request, but they outlive the request/response exchange. A channel remains live until it is explicitly closed or reset, or until the connection is torn down.
A ChannelItem message carries a channel ID and a serialized value of
the channel's element type.
The sender of a channel sends CloseChannel when it is done sending.
After sending CloseChannel, the sender MUST NOT send any more
ChannelItem messages on that channel.
The receiver of a channel sends ResetChannel to ask the sender to
stop sending. After receiving ResetChannel, the sender MUST stop
sending ChannelItem messages on that channel.
Flow control
Vox provides backpressure at two levels: request pipelining limits and per-channel credit-based flow control.
Request limits
Each connection has two independent directional request limits: one for request attempts sent by the local peer, and one for request attempts sent by the counterpart. Each peer advertises the maximum number of concurrent request attempts it is willing to accept on that connection.
A peer's advertised max_concurrent_requests limits how many concurrent
request attempts the other peer may send on that connection.
A peer MUST NOT send a new request attempt if doing so would exceed the
counterpart's advertised max_concurrent_requests for that connection.
If a peer receives a request attempt that exceeds its own advertised
max_concurrent_requests for that connection, it MUST treat that as a
protocol violation.
max_concurrent_requests counts live request attempts, not logical
operations. A retransmission for the same operation still consumes one
unit of request concurrency while that retransmitted request attempt is
live.
Request-attempt accounting is attachment-local. When a conduit attachment fails, in-flight request attempts on that failed attachment are no longer live. If an unresolved operation is retransmitted after session resumption, that later retransmission counts as a new request attempt.
The default limit is carried in ConnectionSettings, which is embedded
in Hello (for the root connection) and OpenConnection (for virtual
connections). See r[session.connection-settings].
Channel credit
Channels use item-based credit for flow control. The receiver of a
channel controls how many items the sender may send by granting credit.
The sender MUST NOT send a ChannelItem if it has zero credit. Each
sent ChannelItem consumes one unit of credit.
Initial credit is part of the channel's type signature. Tx<T, N> and
Rx<T, N> carry a const generic N: usize that specifies the initial
credit for the channel. When a channel is created (as part of a request),
the sender starts with N units of credit. This value
is known at compile time and is part of the channel's type definition,
so both peers agree on it through schema exchange.
N = 0 is valid. The sender MUST wait for an explicit GrantCredit
before sending any items. This is useful for channels where the receiver
needs full control over when data starts flowing.
The receiver of a channel sends a GrantCredit message to add credit.
GrantCredit carries a connection ID, a channel ID, and an additional
count (u32). The sender's available credit increases by additional.
The receiver MAY send GrantCredit at any time after the channel exists.
Credit is strictly additive. There is no mechanism to revoke granted credit. The receiver controls flow by choosing when and how much credit to grant.
When the sender's credit reaches zero, it MUST stop sending ChannelItem
messages on that channel until more credit is granted. The sender SHOULD
apply backpressure to the producing code (e.g. by blocking a send()
call) rather than buffering unboundedly.
Cancellation
A caller may send CancelRequest to indicate it is no longer interested
in the response. The handler SHOULD stop processing the request, but
a response may still arrive — the caller MUST be prepared to ignore it.
Cancelling a request does not automatically close or reset any channels that were created as part of that request. Channels have independent lifecycles and MUST be closed or reset explicitly.
Pipelining
Multiple requests MAY be in flight simultaneously on a connection. Each request is independent; a slow or failed request MUST NOT block other requests.
Metadata
Requests and Responses carry metadata: a list of (key, value, flags)
triples for out-of-band information such as tracing context, authentication
tokens, or deadlines.
A metadata value is one of three types:
String— a UTF-8 stringBytes— an opaque byte bufferU64— a 64-bit unsigned integer
Each metadata entry carries a u64 flags bitfield that controls handling
behavior. Unknown flag bits MUST be preserved when forwarding metadata,
but MUST be ignored for handling decisions.
| Bit | Name | Meaning |
|---|---|---|
| 0 | SENSITIVE | See r[rpc.metadata.flags.sensitive] |
| 1 | NO_PROPAGATE | See r[rpc.metadata.flags.no-propagate] |
| 2–63 | Reserved | MUST be zero when creating; MUST be preserved when forwarding |
When the SENSITIVE flag (bit 0) is set, the value MUST NOT be logged,
traced, or included in error messages. Implementations MUST take care
not to expose sensitive values in debug output, telemetry, or crash reports.
When the NO_PROPAGATE flag (bit 1) is set, the value MUST NOT be
forwarded to downstream calls. A proxy or middleware that forwards
metadata MUST strip entries with this flag set.
Metadata keys are case-sensitive UTF-8 strings. By convention, keys
use lowercase kebab-case (e.g. authorization, trace-parent,
request-deadline).
Duplicate keys are allowed. When multiple entries share the same key, all values MUST be preserved in order.
Unknown metadata keys MUST be ignored — they MUST NOT cause errors or protocol violations.
Examples
Authentication tokens should be marked sensitive to prevent logging:
metadata. push ((
"authorization" . into (),
MetadataValue :: String ( "Bearer sk-..." . into ()),
MetadataFlags :: SENSITIVE ,
)); Session tokens that shouldn't leak to downstream services:
metadata. push ((
"session-id" . into (),
MetadataValue :: String ( session_id),
MetadataFlags :: SENSITIVE | MetadataFlags :: NO_PROPAGATE ,
)); Channel binding
Channel IDs in Request.channels MUST be listed in the order produced by a
schema-driven traversal of the argument types. The traversal visits struct
fields and active enum variant fields in declaration order. It does not
descend into collections, since channels MUST NOT appear there (see
r[rpc.channel.no-collections]). Channels inside an Option that is
None at runtime are simply absent from the list.
Tx<T, N> and Rx<T, N> values in the serialized payload MUST be encoded as
unit placeholders. The actual channel IDs are carried out-of-band in the
channels field of the Request message.
On the callee side, implementations MUST use the channel IDs from
Request.channels as authoritative, patching them into deserialized
argument values before binding streams.
Channel pairs and shared state
channel<T>() returns a (Tx<T, 16>, Rx<T, 16>) pair (default initial
credit N = 16) that share a single
channel core. Both handles hold an Arc reference to the core. The
core contains a Mutex<Option<ChannelBinding>> where ChannelBinding
is either a Sink or a Receiver — never both. The Mutex is
needed because Rx::recv takes the receiver out of the core on
first call.
When the framework binds a channel handle that is part of a pair
(created via channel()), the binding is stored in the shared core.
The paired handle — which the caller or callee kept — reads or takes
the binding from the same core. This allows the framework to bind
both ends by touching only the handle that appears in the args.
Caller-side binding (args)
When the caller sends a request containing channel handles in the
arguments, the framework iterates the channel locations from the
RpcPlan, allocates a channel ID for each, and binds the handle
in the args tuple. Channel IDs are collected into Request.channels.
For an Rx<T> in arg position: the handler will receive, so the
caller must send. The framework allocates a channel ID and creates
a sink (via ChannelBinder::create_tx). The sink is stored in the
shared core so the caller's paired Tx<T> can send through it.
For a Tx<T> in arg position: the handler will send, so the caller
must receive. The framework allocates a channel ID and creates a
receiver (via ChannelBinder::create_rx). The receiver is stored
in the shared core so the caller's paired Rx<T> can receive from it.
Callee-side binding (args)
When the callee receives a request, channel handles in the deserialized
arguments are standalone (not part of a pair). The framework iterates
the channel locations from the RpcPlan and binds each handle directly
using the channel IDs from Request.channels.
For an Rx<T> in arg position: the handler receives. The framework
calls ChannelBinder::register_rx with the channel ID to register
the channel for routing and stores the receiver directly in the
Rx's receiver slot.
For a Tx<T> in arg position: the handler sends. The framework
calls ChannelBinder::bind_tx with the channel ID and stores the
sink directly in the Tx's sink slot.
Handle hot path
Tx::send reads the sink from the shared core. If the Tx was
created standalone (deserialized), it reads from its local sink slot.
If it was created via channel(), it reads from the shared core's
ChannelBinding::Sink.
Rx::recv takes the receiver on first call. If the Rx was created
standalone (deserialized), the receiver is already in its local slot.
If it was created via channel(), the first recv call takes the
receiver from the shared core's ChannelBinding::Receiver into the
local slot. Subsequent calls use the local slot directly.