Client Behaviour¶
This section describes how RpcClient works, including TCP, TLS, mTLS modes
and optional app-level AES encryption.
Encryption note¶
From the client’s point of view:
- The 28-byte uRPC header is never encrypted by the protocol itself.
- TLS/mTLS encrypt the TCP stream, but the header is still parsed as a normal 28-byte structure (magic/version/type/flags/stream_id/method_id/length).
- App-level AES (when enabled,
FLAG_ENCRYPTEDset) encrypts only the payload:IV[12] + ciphertext[...] + TAG[16].
The client always:
- Reads and parses the header in plaintext.
- If
FLAG_ENCRYPTEDis set, decrypts the payload with AES-256-GCM using a key derived from the TLS exporter. - Passes the decrypted body to user code.
Overview¶
RpcClient is a fully asynchronous, multiplexed RPC client built on top of uvent.
Transport is abstracted via the IRpcStream interface, so the client supports:
- TCP (
TcpRpcStream) - TLS (
TlsRpcStream) - mTLS (mutual TLS)
- Optional app-level AES (per-connection key from TLS exporter)
Transport (and whether TLS is used) is selected through
RpcClientConfig.stream_factory.
Whether app-level AES is used is controlled by the TLS-side configuration (shared between client and server).
Internal Structure¶
Key members:
std::shared_ptr<IRpcStream> stream_– active transport (TCP/TLS/mTLS).std::atomic<uint32_t> next_stream_id_{1}– stream ID allocator.std::atomic<bool> running_{false}– reader loop flag.AsyncMutex write_mutex_– serialize writes.AsyncMutex connect_mutex_– serialize connects.AsyncMutex pending_mutex_– protect RPC calls map.AsyncMutex ping_mutex_– protect ping waiters.unordered_map<uint32_t, shared_ptr<PendingCall>> pending_calls_unordered_map<uint32_t, shared_ptr<AsyncEvent>> ping_waiters_
PendingCall:
struct PendingCall {
std::shared_ptr<AsyncEvent> event;
std::vector<uint8_t> response;
bool error{false};
uint32_t error_code{0};
std::string error_message;
};
Connection Establishment¶
RpcClient::ensure_connected():
-
If already connected (
stream_ != nullptrandrunning_ == true) → returnstrue. -
Locks
connect_mutex_. -
Creates a stream via:
Depending on factory:
| Factory | Transport |
|---|---|
TcpRpcStreamFactory |
TCP |
TlsRpcStreamFactory |
TLS/mTLS |
-
On success:
-
stream_ = created stream running_ = true- spawns
reader_loop()viaco_spawn -
optionally spawns
ping_loop()if ping interval is configured -
On failure: returns
false.
For TLS/mTLS:
- TLS handshake happens inside
TlsRpcStream. - Certificate verification is performed according to
TlsClientConfig(CA, hostname, client cert for mTLS, etc.). - If app-level AES is enabled, the per-connection AES key is derived from the TLS exporter during/after the handshake and bound to that stream.
Sending Requests¶
RpcClient::async_call(method_id, request_body):
-
Calls
ensure_connected().- If it fails → returns empty vector (no request is sent).
-
Allocates a new non-zero
stream_id. -
Creates
PendingCallwithAsyncEventand inserts it underpending_mutex_. -
Builds
RpcFrameHeader:type = FrameType::RequestflagsincludesFLAG_END_STREAM-
MAY also include:
FLAG_TLS/FLAG_MTLS(depending on transport)FLAG_ENCRYPTEDif app-level AES is enabled (payload will be AES-256-GCM)
-
Locks
write_mutex_and sends the frame:
If FLAG_ENCRYPTED is used, the client encrypts:
and passes that as the payload.
-
Waits on
call->event->wait(). -
Removes entry from
pending_calls_. -
Returns:
-
decrypted
call->responsefor success - empty vector if
call->error == trueor on connection failure
Name-based and compile-time helpers¶
client->async_call("Service.Method", body);
client->async_call_ct<method_id("Service.Method")>(body);
- The string-based overload hashes the name at runtime (FNV-1a).
- The
_ctoverload uses a compile-time hash.
Reader Loop¶
RpcClient::reader_loop() runs while running_ is true:
-
Captures
stream_. If null → exit. -
Reads header (28 bytes) via
read_exact. EOF or error → exit. -
Parses header. Invalid magic/version → exit.
-
Reads payload if
hdr.length > 0. -
If
FLAG_ENCRYPTEDis present:- Treat payload as
IV[12] + CT + TAG[16]. - Decrypt with AES-256-GCM using the per-connection key from TLS exporter.
-
On decrypt failure:
- treat as protocol/crypto error
- terminate loop (connection closed)
- wake all pending calls with
"Connection closed".
- Treat payload as
After successful decrypt, the loop works with plaintext payload.
- Dispatches by
FrameType:
Response Frames¶
-
Lookup
PendingCallbystream_id. -
If not found → ignore (stale / unexpected).
-
If
FLAG_ERROR:- decode error payload (after decrypt if
FLAG_ENCRYPTED) - set:
- decode error payload (after decrypt if
-
Else:
- copy plaintext payload into
call->response
- copy plaintext payload into
-
Trigger
call->event->set().
Ping Frames¶
If client receives Ping from server:
- Builds a matching
Pongframe. - Sends under
write_mutex_. - No payload; flags mirror end-of-stream and transport bits.
Pong Frames¶
- Lookup waiter in
ping_waiters_bystream_id. - If found → trigger event.
- Used by
async_ping().
Unknown / server-only frames¶
- Logged
- Ignored
Loop Termination¶
When reader_loop() exits:
-
running_ = false. -
For all pending RPCs:
- mark error
"Connection closed". - trigger each
PendingCall::event.
- mark error
-
For all ping waiters:
- trigger events (Pong will never come).
-
Under
connect_mutex_, resetstream_ = nullptr.
All waiting coroutines are guaranteed to be released.
Ping / Pong¶
async_ping() is a built-in liveness probe:
-
ensure_connected(). -
Allocate
stream_id. -
Insert
AsyncEventintoping_waiters_. -
Send
Pingframe:type = Pingflags = FLAG_END_STREAM(+ optionalFLAG_TLS/FLAG_MTLS)- no payload,
FLAG_ENCRYPTEDis not used.
-
Wait for
Pongvia event. -
If waiter is still present → ping success; otherwise considered failed.
Use cases:
- keep-alive
- warm-up
- readiness checks / CLI pre-flight
Closing the Client¶
RpcClient::close():
- sets
running_ = false - swaps out
stream_ - calls
stream->shutdown() reader_loop()exits and does normal cleanup
This is a graceful shutdown path.
TLS and mTLS Support¶
TLS is activated by selecting TlsRpcStreamFactory:
urpc::RpcClientConfig cfg;
cfg.host = "server";
cfg.port = 45900;
// cfg.stream_factory will be set to a TLS-based factory
TlsClientConfig controls:
enabled/verify_peer- CA bundle
- client certificate/key (for mTLS)
- SNI/server_name
- optional app-level AES (key derived via TLS exporter)
mTLS requires:
verify_peer = true- client certificate + key
- matching server-side mTLS setup
From RpcClient’s perspective, TLS and mTLS are just different
IRpcStream implementations; the high-level behaviour of async_call,
reader_loop, async_ping, etc. does not change.
CLI Tool¶
The repository includes a simple CLI client (urpc_cli) using the same core API.
It supports:
- plain TCP
- TLS / mTLS
- app-level AES when TLS is enabled (payload encryption via TLS exporter)
Examples:
TCP¶
TLS (server-auth only, payload encrypted with AES over TLS)¶
urpc_cli --tls --tls-ca ca.crt \
--tls-server-name localhost \
--host 127.0.0.1 --port 45900 \
--method Example.Echo \
--data "hello over tls+aes"
mTLS (mutual TLS, payload encrypted with AES over TLS)¶
urpc_cli --tls \
--tls-ca ca.crt \
--tls-cert client.crt \
--tls-key client.key \
--tls-server-name localhost \
--host 127.0.0.1 --port 45900 \
--method Example.Echo \
--data "hello over mtls+aes"
Behaviour:
- Header (28 bytes) is always visible at uRPC level.
- When TLS is enabled and app-level AES is on, the CLI encrypts only the payload
(request and response bodies) with AES-256-GCM using a key derived from
SSL_export_keying_material, and usesFLAG_ENCRYPTEDin the frame flags.