Recipe 49: Typed RPC Service
What You Build
A typed RPC server using shared-memory FBMU as the transport. Client serializes an EchoRequest into a leased RPC region; the server dispatches it through grafos_rpc::RpcHandler and returns EchoResponse through the same lease-backed transport. No TLS, no HTTP inside the hot path: the lease is the trust boundary.
Source
cookbook/recipe-49-rpc-service/ in the source tree.
The recipe is the host-testable handler plus a real RpcHandler implementation. The handler takes a typed EchoRequest and returns a typed EchoResponse; EchoRpcService is what a grafos_rpc::RpcServer can dispatch.
Core grafOS API Path
The RPC transport is a memory lease plus a client/server pair over fixed request and response regions:
use cookbook_recipe_49_rpc_service::{ EchoRequest, EchoResponse, EchoRpcService, ECHO_METHOD_ID,};use grafos_rpc::{RpcClient, RpcServer};use grafos_std::mem::MemBuilder;
let lease = MemBuilder::new() .min_bytes(64 * 1024) .lease_secs(300) .acquire()?;
let mut client = RpcClient::new(&lease);let server = RpcServer::new(&lease);
// The server side runs this from its event loop.let handled = server.poll_once(&EchoRpcService)?;
// The client side calls with the same typed request/response structs.let response: EchoResponse = client.call( ECHO_METHOD_ID, &EchoRequest::Reverse { message: "abc".into(), },)?;# let _ = (handled, response);# Ok::<(), grafos_std::FabricError>(())In a live service, the RpcClient::call and RpcServer::poll_once calls run
on different tasklets or event-loop turns over the same lease-backed arena.
The recipe crate keeps the handler host-testable, but the hot path is still
MemBuilder to RpcServer/RpcClient to RpcHandler.
#[serde(rename_all = "snake_case")]pub enum EchoRequest { Ping, Echo { message: String }, Reverse { message: String },}
pub fn handle(req: EchoRequest) -> EchoResponse { match req { EchoRequest::Ping => EchoResponse::Pong, EchoRequest::Echo { message } => { if message.len() > 1024 { EchoResponse::Error { reason: "message_too_large".into() } } else { EchoResponse::Echoed { message } } } EchoRequest::Reverse { message } => { EchoResponse::Reversed { message: message.chars().rev().collect() } } }}
pub struct EchoRpcService;
impl RpcHandler for EchoRpcService { fn handle(&self, method_id: u32, payload: &[u8]) -> FabricResult<Vec<u8>> { if method_id != ECHO_METHOD_ID { return Err(FabricError::Unsupported); } let request: EchoRequest = postcard::from_bytes(payload).map_err(|_| FabricError::IoError(-200))?; postcard::to_allocvec(&handle(request)).map_err(|_| FabricError::IoError(-201)) }}What’s interesting
- One typed request enum.
EchoRequestuses serde’s standard enum representation so JSON tasklet input and postcard RPC payloads share the same compiled type. - Errors are values.
EchoResponse::Error { reason }is part of the response enum, not an out-of-band error path. The client deserializes one variant or another; there’s no “is the response valid?” check separate from “what response is it?” - Bounded request sizes.
Echorejects messages over 1024 bytes; the cap is part of the API contract, not a runtime fluke. - Real RPC path.
EchoRpcServiceimplementsgrafos_rpc::RpcHandler; aRpcServercan poll a lease and dispatch requests to it.
Failure Behavior
- Invalid JSON on the tasklet path returns
EchoResponse::Error. - Unknown RPC method ids return
FabricError::Unsupported. - Malformed postcard payloads return
FabricError::IoError(-200). - Oversized echo messages return the typed response value
EchoResponse::Error { reason: "message_too_large" }.
Run And Verify
cargo test -p cookbook-recipe-49-rpc-serviceExpected: the tests cover ping, echo, reverse, size rejection, invalid JSON, and RpcHandler dispatch.
Adapt It
Give each method a stable u32 method id, keep request and response enums versioned, and keep payload caps in the handler so callers get typed refusals before they fill the lease-backed transport.