Wire protocol#
pw_rpc2: Next gen, zero-copy async RPC with end-to-end backpressure
Pigweed RPC2 uses a binary-framed wire protocol designed for low processing overhead. Each packet consists of a fixed-size header (dependent on the packet type) sharing a common prefix followed by type-specific fields. In packets with payloads, the payload is located immediately following the header.
All multi-byte numeric fields are encoded in little-endian byte order.
Connection state and establishment#
As RPC2 runs over a generic Socket interface, a newly established connection
must first identify that the peer is a compatible RPC endpoint before regular
data flow can begin.
To achieve this, RPC2 uses two distinct categories of packets:
Handshake packets are used during the initial establishment phase. They contain magic numbers which identify the sender as an RPC endpoint.
RPC packets are used for regular data exchange. After the handshake completes, all subsequent packets over the connection are one of these.
Three-way handshake#
Each connection initiated by an RPC client and accepted by a server begins with a three-way handshake loosely modeled after TCP. Its purpose is to identify the endpoints and ensure compatibility. In the future, it may be extended to support negotiation of connection parameters.
Handshake flow#
The handshake begins when the client opens the connection.
The client sends a
SYN(type 1) with its protocol version (currently always1).The server responds with a
SYN_ACK(type 2) containing the minimum of its version and the client’s (currently also1).The client sends an
ACK(type 3), accepting the server’s version, to complete the handshake.
As there is currently only version 1, this handshake primarily establishes
that both endpoints speak RPC. However, clients and servers should be written to
accept newer versions from the peer, falling back if they aren’t supported.
If either endpoint receives an invalid or unexpected packet during the handshake, it should terminate the connection.
sequenceDiagram
autonumber
actor C as Initiator (Client)
actor S as Responder (Server)
C->>S: SYN (version = 1)
Note over S: Validate magic ('PRPC')
S->>C: SYN_ACK (negotiated_version)
Note over C: Validate response magic and version
C->>S: ACK (negotiated_version)
Note over C,S: Connection established, begin RPC phase
C->>S: START (service_id, method_id, call_id)
Handshake packet#
The handshake uses a dedicated 8-byte packet.
Offset |
Size |
Name |
Description |
|---|---|---|---|
0 |
4 |
|
|
4 |
1 |
|
Protocol version, currently |
5 |
1 |
|
|
6 |
2 |
|
Written as |
RPC packets#
Once the handshake is complete, RPC packets are sent over the connection.
RPC call flows#
The diagrams below show the lifecycles of each of the four RPC call types.
Packets are labeled with their type flags; see
RPC packet types. Note that the server may always terminate
the RPC early by sending an OK_TERMINAL packet, and either side may abort it
with an ERROR_TERMINAL packet.
Unary RPC#
sequenceDiagram
participant C as Client
participant S as Server
C->>S: START | HAS_PAYLOAD | STREAM_END (request)
S->>C: SERVER | HAS_PAYLOAD | OK_TERMINAL (response)
Server streaming RPC#
sequenceDiagram
participant C as Client
participant S as Server
C->>S: START | HAS_PAYLOAD | STREAM_END (request)
loop Zero or more
S->>C: SERVER | HAS_PAYLOAD (response)
end
S->>C: SERVER | OK_TERMINAL
Client streaming RPC#
sequenceDiagram
participant C as Client
participant S as Server
C->>S: START
loop Zero or more
C->>S: HAS_PAYLOAD (request)
end
C->>S: STREAM_END
S->>C: SERVER | HAS_PAYLOAD | OK_TERMINAL (response)
Bidirectional streaming RPC#
sequenceDiagram
participant C as Client
participant S as Server
C->>S: START
par Client stream
loop Zero or more
C->>S: HAS_PAYLOAD (request)
end
C->>S: STREAM_END
and Server stream
loop Zero or more
S->>C: SERVER | HAS_PAYLOAD (response)
end
end
S->>C: SERVER | OK_TERMINAL
RPC packet header structure#
Every RPC packet begins with a common 5-byte header:
Offset |
Size |
Name |
Description |
|---|---|---|---|
0 |
4 |
|
Client-assigned ID for this specific invocation. Selected when sending the initial request, and echoed afterwards. |
4 |
1 |
|
The type of packet. Determines further fields. |
RPC packet types#
The type byte of an RPC packet is not an enumeration. It is a bitfield of
independent properties, and each packet combines them:
Bit: 7 6 5 4 3 2 1 0
+---+---+---+-----------+--------+-------------+---------+
| 0 | 0 | 0 | CloseMode | START | HAS_PAYLOAD | SERVER |
+---+---+---+-----------+--------+-------------+---------+
Bits |
Mask |
Name |
Meaning |
|---|---|---|---|
0 |
|
|
|
1 |
|
|
The packet carries a message, which may be empty, following its header. A packet without this bit ends with its header. |
2 |
|
|
The packet starts a new call. Its header includes the |
4:3 |
|
|
|
7:5 |
|
Reserved |
Must be |
Only the following 14 combinations are valid. A packet of any other type is rejected as malformed.
Value |
Combination |
Description |
|---|---|---|
|
|
Client stream message. |
|
|
Server stream message. |
|
|
Opens a client or bidirectional streaming call without a message. |
|
|
Opens a client or bidirectional streaming call with its first message. |
|
|
Client half-closes its stream. |
|
|
Server half-closes its stream. The RPC continues until the server ends it
with |
|
|
Client’s final stream message, closing its stream. |
|
|
Server’s final stream message, half-closing its stream. Like |
|
|
Opens a client or bidirectional streaming call and immediately closes the client’s stream without sending a message. |
|
|
Starts a unary or server streaming call with its request, which may be empty. Also valid for a client streaming call with a single message. |
|
|
Server finishes its stream and completes the RPC. |
|
|
Server’s response, completing a unary or client streaming RPC. |
|
|
Client aborts the RPC. Carries an 8-bit RPC error code. |
|
|
Server aborts the RPC. Carries an 8-bit RPC error code. |
A packet without HAS_PAYLOAD must end with its header. A receiver rejects
any packet without HAS_PAYLOAD that has trailing bytes as malformed.
When the server ends the RPC with 10001 or 10011, the client stops
sending on any stream it still has open.
Server half-close#
A server may half-close its stream with 01001 or 01011 to stop
sending messages while it continues to read the client’s stream. This does not
end the RPC: the server still sends a terminal packet (10001, 10011,
or 11001) later, which carries the call’s final status.
The protocol allows either the client or the server to half-close its stream. The C++ server currently always ends the RPC when it closes its stream, for consistency with gRPC. Server half-close packets are valid, however, so every client must handle them: after a server half-close, a client drops any further server messages and waits for the terminal packet before completing the call.
Method type mismatches#
Because the type byte states what a packet contains, an endpoint does not rely
on its own view of a method’s type to parse a packet. If the client and server
disagree about a method’s type, the endpoint that receives a packet its method
cannot accept fails the call with METHOD_TYPE_MISMATCH instead of misreading
or dropping a message:
A unary or server streaming method takes a single request, so the server accepts only
01110(START | HAS_PAYLOAD | STREAM_END) to start it. It rejects any otherSTARTpacket with the server errorMETHOD_TYPE_MISMATCHwithout invoking the method.A unary or client streaming call takes a single response, so the client accepts only
10011(SERVER | HAS_PAYLOAD | OK_TERMINAL) or an error (11001) from the server. For any other packet, it sends the client errorMETHOD_TYPE_MISMATCH, which aborts the call on the server, before delivering anything from the packet. If the packet already ended the RPC (10001), the client sends nothing.
Either way, the client’s call fails because of METHOD_TYPE_MISMATCH. The
C++ client reports this as kUnimplemented; see
Error handling.
A streaming endpoint accepts 01110 or 10011, which represent a
stream of a single message. A mismatch that produces only these packets
completes normally because no message is lost or misread:
Client’s method type |
Server’s method type |
Result |
|---|---|---|
Unary |
Client streaming |
Works: the client sends |
Unary |
Server streaming |
Fails, unless the server replies with a single |
Unary |
Bidirectional streaming |
Fails, unless the server replies with a single |
Server streaming |
Unary |
Works: the server’s |
Server streaming |
Client streaming |
Works, as above. |
Server streaming |
Bidirectional streaming |
Works: the client sends |
Client streaming |
Unary or server streaming |
Fails: the server requires |
Client streaming |
Bidirectional streaming |
Fails, unless the server replies with a single |
Bidirectional streaming |
Unary or server streaming |
Fails: the server requires |
Bidirectional streaming |
Client streaming |
Works: the server’s |
Unexpected packets#
An endpoint handles packets that it does not expect as follows. It never
replies to a terminal packet (10001, 10011, 11000, or 11001):
the sender has already finished with the call, and replying to an error with an
error could bounce between the endpoints forever.
A packet for the wrong endpoint. The receiver drops a packet type that only its own role may send, such as a server message arriving at a server. If the packet is not terminal, the receiver replies with
RECEIVED_PACKET_FOR_WRONG_ENDPOINT. If a call with the packet’s call ID is active, it ends with a protocol error.A non-terminal packet for an unknown call. The receiver drops the packet and replies with
RECEIVED_PACKET_FOR_UNKNOWN_CALL. When both endpoints behave correctly, this only happens in a race with the end of the call: the receiver has already finished with the call, and so has the peer by the time the reply arrives, so the reply has no effect. If an active call receivesRECEIVED_PACKET_FOR_UNKNOWN_CALL, the endpoints are out of sync, and the call ends with a protocol error.A terminal packet for an unknown call. The receiver drops the packet without replying.
A START packet for an active call. The client reused the ID of a call that is still active. The server drops the new
STARTpacket, ends the active call, and replies withRECEIVED_DUPLICATE_START, which ends the call on the client. Neither call proceeds.A message after the peer ended its stream. The receiver drops the message without replying, and still applies any stream end or terminal status in the same packet.
Packet structures#
The fields of each RPC packet are listed below.
Payload framing
RPC2 runs over a datagram socket, so each packet from the peer is received in full. There is no length field in the protocol itself. Transport implementations used with RPC must ensure that buffers read from the peer over an RPC connection contain only, and exactly, the bytes of a single packet without any padding.
Start packets (START set)#
Starts an RPC. Its header size is 13 bytes.
Offset |
Size |
Name |
Description |
|---|---|---|---|
0 |
5 |
Header |
Common RPC packet header with |
5 |
4 |
|
ID of the service to invoke |
9 |
4 |
|
ID of the method to invoke |
13 |
Variable |
Payload |
Remainder of the packet, present only if |
Error packets (ERROR_TERMINAL set)#
Aborts an RPC call immediately. Its header size is 6 bytes.
Offset |
Size |
Name |
Description |
|---|---|---|---|
0 |
5 |
Header |
Common RPC packet header with |
5 |
1 |
|
RPC error code |
All other packets#
Every other packet has a 5-byte header consisting only of the common RPC
packet header. If HAS_PAYLOAD is set, the payload is the remainder of the
packet.
RPC error codes#
Error packets (11000 and 11001) carry an 8-bit RPC error code. Every
code names exactly one condition, so that an endpoint’s logs say precisely why
the peer ended a call.
The top two bits of a code identify which endpoint may send it:
Top bits |
Range |
Origin |
|---|---|---|
|
|
Either endpoint. Valid in both client ( |
|
|
Server only. Only valid in server error packets ( |
|
|
Client only. Only valid in client error packets ( |
|
|
Reserved for future protocol versions. Not valid in any error packet in protocol version 1. |
Each protocol version defines a set of error codes. An endpoint must not send an error code that the negotiated protocol version does not define. A receiver treats any code it does not recognize, any code that the sender may not send, and zero as a protocol violation.
As a result, a well-behaved peer never sends a code that the receiver does not know. A later protocol version may add codes. When the negotiated version is older than the newest version that an endpoint supports, the endpoint sends the closest code that the negotiated version defines.
The tables below list the codes that protocol version 1 defines. Every other value is unassigned or reserved. The C++ implementation reports each code to the application as the action it calls for; see Error handling.
Value |
Name |
Description |
|---|---|---|
|
|
No error. Never sent. |
|
|
A bug in the RPC implementation. |
|
|
Application code cancelled the call, either explicitly or, on the client, by dropping a handle to the call before the call finished. |
|
|
A packet does not match the method’s type. Unary and server streaming
calls must be started with |
|
|
The endpoint received a packet type that may only be sent by its own role (a server received a server-to-client packet, or a client received a client-to-server packet). |
|
|
The endpoint received a non-terminal packet for a call ID that it does not know about. See Unexpected packets. |
The server-only codes are in the order of a call’s lifecycle: 0x40 to
0x44 reject a call before its method runs (0x42 also ends the active
call that has the same ID), 0x45 and 0x46 end a call for a reason
outside the method’s control after the method started, and 0x47 and
0x48 report that the server’s application code ended the call.
Value |
Name |
Description |
|---|---|---|
|
|
The requested service is not registered on the server. |
|
|
The requested method is not registered on the target service. |
|
|
The server received a |
|
|
The request payload could not be decoded. |
|
|
The server could not allocate state for the call. The method never ran. |
|
|
An allocation failed after the method started. |
|
|
The target service was unregistered from the server while the call was running. A server that does not support unregistering services never sends this code. |
|
|
The handle that a unary or client streaming method uses to respond was destroyed without responding or cancelling. |
|
|
The method’s implementation completed while a handle it handed out for writing still held the call open. |