From 3e61061a5fce01edd5f75f079104a8ab68afc865 Mon Sep 17 00:00:00 2001 From: Ruediger Klaehn Date: Thu, 3 Jul 2025 20:20:27 +0300 Subject: [PATCH 01/12] blog post about irpc --- src/app/blog/irpc/page.mdx | 257 +++++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 src/app/blog/irpc/page.mdx diff --git a/src/app/blog/irpc/page.mdx b/src/app/blog/irpc/page.mdx new file mode 100644 index 00000000..4fc134ac --- /dev/null +++ b/src/app/blog/irpc/page.mdx @@ -0,0 +1,257 @@ +import { BlogPostLayout } from '@/components/BlogPostLayout' +import {ThemeImage} from '@/components/ThemeImage' + +export const post = { + draft: false, + author: 'Rüdiger Klaehn', + date: '2025-07-04', + title: 'IRPC', + description: + "A lightweight rpc crate for iroh protocols", +} + +export const metadata = { + title: post.title, + description: post.description, + openGraph: { + title: post.title, + description: post.description, + images: [{ + url: `/api/og?title=Blog&subtitle=${post.title}`, + width: 1200, + height: 630, + alt: post.title, + type: 'image/png', + }], + type: 'article' + } +} + +export default (props) => + +# IRPC - a lightweight rpc crate for iroh connections + +When writing async rust code such as iroh protocols, you will frequently use message passing to communicate between independent parts of your code. + +You will start by defining a message enum that contains the different requests your task is supposed to handle, and then write a loop inside the handler task, like a very primitive version of an actor. + +Let's do a simple example, an async key value store, with just Set and Get. + +```rust +enum Request { + Set { + key: String, + value: String, + response: oneshot::Sender<()>, + } + Get { + key: String, + response: oneshot::Sender>, + } +} +``` + +Your "client" then is a tokio `mpsc::Sender` or a small wrapper around it that makes it more convenient to use. And your server is a task that contains a handler loop. + +Calling such a service is quite cumbersone, e.g. calling Get: + +```rust +let (tx, rx) = oneshot::channel(); +client.send(Command::Get { key: "a".to_string(), response: tx }).await?; +let res = rx.await?; +``` + +So you will usually write a client struct that is a newtype wrapper around the mpsc Sender to add some syntax candy: + +```rust +struct Client(mpsc::Sender); +impl Client { + ... + async fn get(&self, key: String) -> Result> { + let (tx, rx) = oneshot::channel(); + self.0.send(Request::Get { key, response: tx }).await?; + Ok(rx.await??) + } + ... +} +``` + +If you want to have some more complex requests, no problem. E.g. here is how a request would look like to add an entry from a stream: + +```rust +enum Request { + ... + SetFromStrean { + key: String, + value: mpsc::Receiver, + response: oneshot::Sender<()>, + } + ... +} +``` + +Or a request that gets a value as a stream: + +```rust +enum Request { + ... + GetAsStream { + key: String, + response: mpsc::Sender>, + } + ... +} +``` + +You already have an async boundary and a message passing based protocol, so it seems like it would be easy to also use this protocol across a process boundary. But you still want to retain the ability to use it in-process with zero overhead. + +To cross a process boundary, the commands have to be serializable. But the response or update channels are not. We need to separate the message itself and the update and response channels. + +At this point things start to get quite verbose: + +``` +#[derive(Serialize, Deserialize)] +struct GetRequest { + key: String, +} + +#[derive(Serialize, Deserialize)] +struct SetRequest { + key: String, + value: String, +} + +/// the serializable request. This is what the remote side reads first to know what to do +#[derive(Serialize, Deserialize)] +enum Request { + Get(GetRequest), + Set(SetRequest), +} + +/// the full request. This is what is used in-process. +enum RequestWithChannels { + Get { request: GetRequest, response: oneshot::Sender }, + Set { request: SetRequest, response: oneshot::Sender<()> }, +} + +impl From for Request { ... } +``` + +How does the actual cross process communication look like, for example for get? Let's use postcard for serialization/deserialization: + +```rust +async fn get_remote(connection: Connection, key: String) -> Result> { + let (send, recv) = connection.open_bi().await?; + send.write_all(postcard::to_stdvec(GetRequest { key })?).await?; + let res = recv.read_to_end(1024).await?; + let res = postcard::from_bytes(&res)?; + Ok(res) +} +``` + +The server side looks similar. We read a `Request` from an incoming connection, then based on the enum case decide which request we need to handle: + +``` +async fn server(connection: Connection, store: BTreeMap) -> Result<()> { + while let Ok((send, recv)) = connection.accept_bi().await { + let request = recv.read_to_end(1024).await?; + let request: Request = postcard::from_bytes(&request)?; + match request { + Request::Get(GetRequest { key }) => { + let response = store.get(key); + let response = postcard::to_stdvec(&response)?; + send.write_all(&response).await?; + send.finish(); + } + ... + } + + } +} +``` + +This works well for simple requests where there is no update channel and just a single response. But we also want to support requests with updates like `SetFromStrean` and requests with stream responses like `GetAsStream`. + +To support this efficiently, it is best to length prefix both the initial request, subsequent updates, and responses. Even if a `Request` "knows" its own size, deserializing from an async stream is very inefficient. + +Since we are using postcard for ser/de, and messages will very frequently be small, we have decided to use postcard varints as length prefixes. + + +Now we have a protocol that supports different rpc types (rpc, client streaming, server streaming, bidi streaming) and that can be used both locally (via the `FullRequest` enum) and remotely. + +But we said that we wanted to be able to seamlessly switch between remote or local. So let's do that (length prefixes omitted): + +```rust +enum Client { + Local(mpsc::Sender), + Remote(quinn::Connection), +} + +impl Client { + async fn get(&self, key: String) -> Result> { + let request = GetRequest { key }; + match self { + Self::Local(chan) => { + let (tx, rx) = oneshot::channel(); + let request = FullRequest { request, response: tx }; + chan.send(request).await?; + Ok(rx.await??) + } + Self::Remote(conn) => { + let (send, recv) = connection.open_bi().await?; + send.write_all(postcard::to_stdvec(request)?).await?; + let res = recv.read_to_end(1024).await?; + let res = postcard::from_bytes(&res)?; + Ok(res) + } + } + } +} +``` + +This is all pretty straightforward code, but very tedious to write, especially for a large and complex protocol. + +There is some work that we can't avoid. We have to define the different request types. We have to specify for each request type if there is no response, a single resposne, or a stream of responses. We also have to specify if there is a stream of updates, and make sure that all these types (requests, updates and responses) are serializable, which can sometimes be a pain when it comes to error types. + +But what about all this boilerplate? +- Defining the two different enums for a serializable request and a full request including channels +- Implementing a client with async fns for each request type +- Implementing a server that reads messages and dispatches on them +- serializing and deserializing using postcard with length prefixes + +The irpc crate is meant solely to reduce the tedious boilerplate involved in writing the above manually. + +It does *not* abstract over the connection type - it only supports [iroh-quinn] send- and receive streams out of the box, so the only two possible connection types are iroh p2p QUIC connections and normal QUIC connections. It also does not abstract over the local channel type - a local channel is always a tokio mpsc channel. Serialization is always postcard, and length prefixes are always postcard varints. + +So let's take a look how the kv service looks using irpc: + +The service definition contains just what is absolutely needed. For each request type we have to define what the response item type is (in this case `String` or `()`), and what the response channel type is (none, oneshot or mpsc). + +The rpc_requests macro will store this information and also create the `RequestWithChannels` enum that adds the appropriate channels for each request type. It will also generate a number of `From`-conversions to make working with the requests more pleasant. + +```rust +struct KvService {} +impl Service for KvStoreService {} + +#[rpc_requests(KvService, message = RequestWithChannels)] +#[derive(Serialize, Deserialize)] +enum Request { + #[rpc(tx=oneshot::Sender)] + Get(GetRequest), + #[rpc(tx=oneshot::Sender<()>)] + Put(PutRequest), +} +``` + +Now let's look at the client: + +```rust +struct Client(irpc::Client); +impl Client { + fn get(&self, key: String) -> Result> { + Ok(self.0.rpc(GetRequest { key }).await?) + } +} +``` + +The fn `rpc` on irpc::Client will only be available for messages where the update channel is not set and the response channel is an oneshot channel, so you will get compile errors if you try to use a request in the wrong way. \ No newline at end of file From 06fc73554bd088ccd9df96cae9816dd9691d66ad Mon Sep 17 00:00:00 2001 From: Ruediger Klaehn Date: Thu, 3 Jul 2025 20:32:15 +0300 Subject: [PATCH 02/12] Fix code examples --- src/app/blog/irpc/page.mdx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app/blog/irpc/page.mdx b/src/app/blog/irpc/page.mdx index 4fc134ac..b89a23bd 100644 --- a/src/app/blog/irpc/page.mdx +++ b/src/app/blog/irpc/page.mdx @@ -109,7 +109,7 @@ To cross a process boundary, the commands have to be serializable. But the respo At this point things start to get quite verbose: -``` +```rust #[derive(Serialize, Deserialize)] struct GetRequest { key: String, @@ -128,7 +128,7 @@ enum Request { Set(SetRequest), } -/// the full request. This is what is used in-process. +/// the full request including response channels. This is what is used in-process. enum RequestWithChannels { Get { request: GetRequest, response: oneshot::Sender }, Set { request: SetRequest, response: oneshot::Sender<()> }, @@ -151,7 +151,7 @@ async fn get_remote(connection: Connection, key: String) -> Result) -> Result<()> { while let Ok((send, recv)) = connection.accept_bi().await { let request = recv.read_to_end(1024).await?; @@ -174,8 +174,7 @@ This works well for simple requests where there is no update channel and just a To support this efficiently, it is best to length prefix both the initial request, subsequent updates, and responses. Even if a `Request` "knows" its own size, deserializing from an async stream is very inefficient. -Since we are using postcard for ser/de, and messages will very frequently be small, we have decided to use postcard varints as length prefixes. - +Since we are using postcard for ser/de, and messages will very frequently be small, we have decided to use postcard varints as length prefixes. Now we have a protocol that supports different rpc types (rpc, client streaming, server streaming, bidi streaming) and that can be used both locally (via the `FullRequest` enum) and remotely. From d11b4fb20666ff5a8144010dcd11dda0fed99c38 Mon Sep 17 00:00:00 2001 From: Ruediger Klaehn Date: Thu, 3 Jul 2025 20:33:53 +0300 Subject: [PATCH 03/12] spelling --- src/app/blog/irpc/page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/blog/irpc/page.mdx b/src/app/blog/irpc/page.mdx index b89a23bd..b82e27ca 100644 --- a/src/app/blog/irpc/page.mdx +++ b/src/app/blog/irpc/page.mdx @@ -53,7 +53,7 @@ enum Request { Your "client" then is a tokio `mpsc::Sender` or a small wrapper around it that makes it more convenient to use. And your server is a task that contains a handler loop. -Calling such a service is quite cumbersone, e.g. calling Get: +Calling such a service is quite cumbersome, e.g. calling Get: ```rust let (tx, rx) = oneshot::channel(); From b709a7ea81ac401e8efcc404b5c4c3cbae8bb3fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cramfox=E2=80=9D?= <“kasey@n0.computer”> Date: Thu, 3 Jul 2025 21:37:13 -0400 Subject: [PATCH 04/12] small nits and added a conclusion --- src/app/blog/irpc/page.mdx | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/app/blog/irpc/page.mdx b/src/app/blog/irpc/page.mdx index b82e27ca..dda709ae 100644 --- a/src/app/blog/irpc/page.mdx +++ b/src/app/blog/irpc/page.mdx @@ -31,11 +31,11 @@ export default (props) => # IRPC - a lightweight rpc crate for iroh connections -When writing async rust code such as iroh protocols, you will frequently use message passing to communicate between independent parts of your code. +When writing async rust code, like you do when writing iroh protocols, you will frequently use message passing to communicate between independent parts of your code. You will start by defining a message enum that contains the different requests your task is supposed to handle, and then write a loop inside the handler task, like a very primitive version of an actor. -Let's do a simple example, an async key value store, with just Set and Get. +Let's do a simple example, an async key-value store, with just `Set` and `Get` requests. ```rust enum Request { @@ -53,7 +53,7 @@ enum Request { Your "client" then is a tokio `mpsc::Sender` or a small wrapper around it that makes it more convenient to use. And your server is a task that contains a handler loop. -Calling such a service is quite cumbersome, e.g. calling Get: +Calling such a service is quite cumbersome. For example, here's what it takes to call `Get`: ```rust let (tx, rx) = oneshot::channel(); @@ -61,7 +61,7 @@ client.send(Command::Get { key: "a".to_string(), response: tx }).await?; let res = rx.await?; ``` -So you will usually write a client struct that is a newtype wrapper around the mpsc Sender to add some syntax candy: +So you will usually write a client struct that is a newtype wrapper around the `mpsc::Sender` to add some syntax candy: ```rust struct Client(mpsc::Sender); @@ -76,7 +76,7 @@ impl Client { } ``` -If you want to have some more complex requests, no problem. E.g. here is how a request would look like to add an entry from a stream: +If you want to have some more complex requests, no problem. Here is what a request that adds and entry from a stream would look like: ```rust enum Request { @@ -103,7 +103,7 @@ enum Request { } ``` -You already have an async boundary and a message passing based protocol, so it seems like it would be easy to also use this protocol across a process boundary. But you still want to retain the ability to use it in-process with zero overhead. +And since you already have an async boundary and a message passing based protocol, it seems like it would be easy to also use this protocol across a process boundary. But you still want to retain the ability to use it in-process with zero overhead. To cross a process boundary, the commands have to be serializable. But the response or update channels are not. We need to separate the message itself and the update and response channels. @@ -137,7 +137,7 @@ enum RequestWithChannels { impl From for Request { ... } ``` -How does the actual cross process communication look like, for example for get? Let's use postcard for serialization/deserialization: +What does the actual cross-process communication look like? Let's take a look at a `Get` example, using postcard for serialization/deserialization: ```rust async fn get_remote(connection: Connection, key: String) -> Result> { @@ -149,7 +149,7 @@ async fn get_remote(connection: Connection, key: String) -> Result) -> Result<()> { @@ -210,7 +210,7 @@ impl Client { This is all pretty straightforward code, but very tedious to write, especially for a large and complex protocol. -There is some work that we can't avoid. We have to define the different request types. We have to specify for each request type if there is no response, a single resposne, or a stream of responses. We also have to specify if there is a stream of updates, and make sure that all these types (requests, updates and responses) are serializable, which can sometimes be a pain when it comes to error types. +There is some work that we can't avoid. We have to define the different request types. We have to specify for each request type the kind of response we expect (no response, a single response, or a stream of responses). We also have to specify if there is a stream of updates and make sure that all these types (requests, updates and responses) are serializable, which can sometimes be a pain when it comes to error types. But what about all this boilerplate? - Defining the two different enums for a serializable request and a full request including channels @@ -218,15 +218,15 @@ But what about all this boilerplate? - Implementing a server that reads messages and dispatches on them - serializing and deserializing using postcard with length prefixes -The irpc crate is meant solely to reduce the tedious boilerplate involved in writing the above manually. +**The `irpc` crate is meant solely to reduce the tedious boilerplate involved in writing the above manually.** -It does *not* abstract over the connection type - it only supports [iroh-quinn] send- and receive streams out of the box, so the only two possible connection types are iroh p2p QUIC connections and normal QUIC connections. It also does not abstract over the local channel type - a local channel is always a tokio mpsc channel. Serialization is always postcard, and length prefixes are always postcard varints. +It does *not* abstract over the connection type - it only supports [iroh-quinn] send and receive streams out of the box, so the only two possible connection types are `iroh` p2p QUIC connections and normal QUIC connections. It also does not abstract over the local channel type - a local channel is always a `tokio::sync::mpsc` channel. Serialization is always using postcard and length prefixes are always postcard varints. -So let's take a look how the kv service looks using irpc: +So let's see what our kv service looks using `irpc`: The service definition contains just what is absolutely needed. For each request type we have to define what the response item type is (in this case `String` or `()`), and what the response channel type is (none, oneshot or mpsc). -The rpc_requests macro will store this information and also create the `RequestWithChannels` enum that adds the appropriate channels for each request type. It will also generate a number of `From`-conversions to make working with the requests more pleasant. +The `rpc_requests` macro will store this information and also create the `RequestWithChannels` enum that adds the appropriate channels for each request type. It will also generate a number of `From`-conversions to make working with the requests more pleasant. ```rust struct KvService {} @@ -253,4 +253,9 @@ impl Client { } ``` -The fn `rpc` on irpc::Client will only be available for messages where the update channel is not set and the response channel is an oneshot channel, so you will get compile errors if you try to use a request in the wrong way. \ No newline at end of file +The `rpc` method on `irpc::Client` will only be available for messages where the update channel is not set and the response channel is an oneshot channel, so you will get compile errors if you try to use a request in the wrong way. + +## Try it out +If you are writing an `iroh` protocol and have run into the same tedious boiler plate issues around RPC as we have, give `irpc` a shot. We've spent a lot of time iterating on this issue, in fact this is the second crate we've published that takes a stable at easing the RPC burden. Take a look at the (no longer maintained) [`quic-rpc`](https://github.com/n0-computer/quic-rpc) if you are curious. + +Because of this extensive experience, we are confident that `irpc` is a good solution for doing both in-process, cross-process, and cross-machine RPC, especially if you are building an `iroh` protocol. Check it out and you will see why we at number0 use it for all of the `iroh` protocols that we have created and maintained. From da5a586de9e4d39b509c5ce47153dacdbf2838d4 Mon Sep 17 00:00:00 2001 From: Ruediger Klaehn Date: Fri, 4 Jul 2025 19:19:13 +0300 Subject: [PATCH 05/12] code fixes --- src/app/blog/irpc/page.mdx | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/app/blog/irpc/page.mdx b/src/app/blog/irpc/page.mdx index dda709ae..9b769252 100644 --- a/src/app/blog/irpc/page.mdx +++ b/src/app/blog/irpc/page.mdx @@ -247,7 +247,7 @@ Now let's look at the client: ```rust struct Client(irpc::Client); impl Client { - fn get(&self, key: String) -> Result> { + async fn get(&self, key: String) -> Result> { Ok(self.0.rpc(GetRequest { key }).await?) } } @@ -255,6 +255,37 @@ impl Client { The `rpc` method on `irpc::Client` will only be available for messages where the update channel is not set and the response channel is an oneshot channel, so you will get compile errors if you try to use a request in the wrong way. +Ok, that's pretty nice. But then pure rpc requests are also pretty simple. What about more complex requests? Let's look at a very simple example of a bidirectional streaming rpc request. An echo request gets a stream of updates (strings) and just echoes them back, until the update stream stops. + +```rust +struct EchoService {} +impl Service for EchoService {} + +#[rpc_requests(KvService, message = RequestWithChannels)] +#[derive(Serialize, Deserialize)] +enum Request { + #[rpc(rx=mpsc::Receiver, tx=mpsc::Sender)] + Echo(EchoRequest), +} +``` + +Let's look at the client. + +```rust +struct Client(irpc::Client); +impl Client { + async fn echo(&self) -> Result<(Sender, Receiver)> { + Ok(self.0.bidi_streaming(EchoRequest, 32, 32).await?) + } +} +``` + +Calling echo will write the initial request to the remote, then return a handle `irpc::channel::mpsc::Sender` that can be used to send updates, and a handle `irpc::channel::mpsc::Receiver` to receive the echos. + +In the in-process case, sender and receiver are just wrappers around tokio channels. In the networking case, a receiver is a wrapper around an [`RecvStream`] that reads and deserializes length prefixed messsages, and a sender is a wrapper around a SendStream that serializes and writes length prefixed messages. + +The client fn can then transform these two handles for the update and response end to make the result more convenient, e.g. by converting the result into a futures [`Stream`]. + ## Try it out If you are writing an `iroh` protocol and have run into the same tedious boiler plate issues around RPC as we have, give `irpc` a shot. We've spent a lot of time iterating on this issue, in fact this is the second crate we've published that takes a stable at easing the RPC burden. Take a look at the (no longer maintained) [`quic-rpc`](https://github.com/n0-computer/quic-rpc) if you are curious. From 98464b4a6d552795354a47e14213d5dbd32e9f26 Mon Sep 17 00:00:00 2001 From: Ruediger Klaehn Date: Thu, 10 Jul 2025 14:05:11 +0300 Subject: [PATCH 06/12] Add more links and a few paragraphs about streams. --- src/app/blog/irpc/page.mdx | 47 ++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/app/blog/irpc/page.mdx b/src/app/blog/irpc/page.mdx index 9b769252..4a9e08c0 100644 --- a/src/app/blog/irpc/page.mdx +++ b/src/app/blog/irpc/page.mdx @@ -53,7 +53,7 @@ enum Request { Your "client" then is a tokio `mpsc::Sender` or a small wrapper around it that makes it more convenient to use. And your server is a task that contains a handler loop. -Calling such a service is quite cumbersome. For example, here's what it takes to call `Get`: +Calling such a service without a client wrapper is quite cumbersome. For example, here's what it takes to call `Get`: ```rust let (tx, rx) = oneshot::channel(); @@ -103,7 +103,7 @@ enum Request { } ``` -And since you already have an async boundary and a message passing based protocol, it seems like it would be easy to also use this protocol across a process boundary. But you still want to retain the ability to use it in-process with zero overhead. +Since you already have an async boundary and a message passing based protocol, it seems like it would be easy to also use this protocol across a process boundary. But you still want to retain the ability to use it in-process with zero overhead. To cross a process boundary, the commands have to be serializable. But the response or update channels are not. We need to separate the message itself and the update and response channels. @@ -137,7 +137,7 @@ enum RequestWithChannels { impl From for Request { ... } ``` -What does the actual cross-process communication look like? Let's take a look at a `Get` example, using postcard for serialization/deserialization: +What does the actual cross-process communication look like? Let's take a look at a `Get` example, using [postcard] for serialization/deserialization: ```rust async fn get_remote(connection: Connection, key: String) -> Result> { @@ -210,7 +210,7 @@ impl Client { This is all pretty straightforward code, but very tedious to write, especially for a large and complex protocol. -There is some work that we can't avoid. We have to define the different request types. We have to specify for each request type the kind of response we expect (no response, a single response, or a stream of responses). We also have to specify if there is a stream of updates and make sure that all these types (requests, updates and responses) are serializable, which can sometimes be a pain when it comes to error types. +There is some work that we can't avoid. We have to define the different request types. We have to specify for each request type the kind of response we expect (no response, a single response, or a stream of responses). We also have to specify if there are updates and make sure that all these types (requests, updates and responses) are serializable, which can sometimes be a pain when it comes to error types. But what about all this boilerplate? - Defining the two different enums for a serializable request and a full request including channels @@ -282,11 +282,44 @@ impl Client { Calling echo will write the initial request to the remote, then return a handle `irpc::channel::mpsc::Sender` that can be used to send updates, and a handle `irpc::channel::mpsc::Receiver` to receive the echos. -In the in-process case, sender and receiver are just wrappers around tokio channels. In the networking case, a receiver is a wrapper around an [`RecvStream`] that reads and deserializes length prefixed messsages, and a sender is a wrapper around a SendStream that serializes and writes length prefixed messages. +In the in-process case, sender and receiver are just wrappers around tokio channels. In the networking case, a receiver is a wrapper around a [RecvStream] that reads and deserializes length prefixed messsages, and a sender is a wrapper around a [SendStream] that serializes and writes length prefixed messages. -The client fn can then transform these two handles for the update and response end to make the result more convenient, e.g. by converting the result into a futures [`Stream`]. +The client fn can then add helper functions that transform these two handles for the update and response end to make the result more convenient, e.g. by converting the result into a futures [Stream] or the updates into a futures [Sink]. But the purpose of irpc is to reduce the boilerplate for defining services that can be used in-process or across processes, not to provide an opinionated high level API. + +For stream based rpc calls, there is an issue you should be aware of. The quinn [SendStream] will send a finish message when dropped. So if you have a finite stream, you might want to have an explicit end marker that you send before dropping the sender to allow the remote side to distinguish between successful termination and abnormal termination. E.g. the `SetFromStrean` request from above should look like this, and you should explicitly send a `Done` request after the last item. + +```rust +#[rpc_requests(KvService, message = RequestWithChannels)] +enum Request { + ... + #[rpc(rx=mpsc::Receiver, tx=oneshot::Sender<()>)] + SetFromStream(SetFromStreamRequest), + ... +} + +enum SetUpdate { + Data(String), + Done, +} +``` + +## What if you don't want rpc over iroh-quinn channels? + +If you integrate iroh protocols into an existing application, it could be that you already have a rpc system that you are happy with, like grpc or json-rpc over websockets. + +In that case, the cross process facilities of irpc will not be useful for you. But crates that use irpc will always have a very cleanly defined protocol consisting of a *serializable* request enum and serializable update and response types. Piping these messages over a different rpc transport is relatively easy. + +When used purely in-memory, irpc is extremely lightweight when it comes to [dependencies](https://github.com/n0-computer/irpc/blob/5cc624832cfed2653a20442851c203935039d6bc/Cargo.toml#L15). Only serde, tokio, tokio-util and thiserror, all of which you probably have in your dependency tree anyway if you write async rust. ## Try it out -If you are writing an `iroh` protocol and have run into the same tedious boiler plate issues around RPC as we have, give `irpc` a shot. We've spent a lot of time iterating on this issue, in fact this is the second crate we've published that takes a stable at easing the RPC burden. Take a look at the (no longer maintained) [`quic-rpc`](https://github.com/n0-computer/quic-rpc) if you are curious. + +If you are writing an `iroh` protocol and have run into the same tedious boiler plate issues around RPC as we have, give `irpc` a shot. We've spent a lot of time iterating on this issue, in fact this is the second crate we've published that takes a stable at easing the RPC burden. Take a look at the [`quic-rpc`](https://github.com/n0-computer/quic-rpc) if you are curious. Because of this extensive experience, we are confident that `irpc` is a good solution for doing both in-process, cross-process, and cross-machine RPC, especially if you are building an `iroh` protocol. Check it out and you will see why we at number0 use it for all of the `iroh` protocols that we have created and maintained. + +[postcard]: https://docs.rs/postcard/latest/postcard/ +[iroh-quinn]: https://docs.rs/iroh-quinn/latest/iroh_quinn/ +[RecvStream]: https://docs.rs/iroh-quinn/latest/iroh_quinn/struct.RecvStream.html +[SendStream]: https://docs.rs/iroh-quinn/latest/iroh_quinn/struct.SendStream.html +[Stream]: https://docs.rs/futures/latest/futures/prelude/trait.Stream.html +[Sink]: https://docs.rs/futures/latest/futures/sink/trait.Sink.html From 81de056408616864eddf13906810c4a9b0cfd813 Mon Sep 17 00:00:00 2001 From: Ruediger Klaehn Date: Thu, 10 Jul 2025 14:38:32 +0300 Subject: [PATCH 07/12] Add paragraph about serializing errors --- src/app/blog/irpc/page.mdx | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/app/blog/irpc/page.mdx b/src/app/blog/irpc/page.mdx index 4a9e08c0..7953e668 100644 --- a/src/app/blog/irpc/page.mdx +++ b/src/app/blog/irpc/page.mdx @@ -303,13 +303,34 @@ enum SetUpdate { } ``` +## Errors + +All irpc requests, updates and responses need to be serializable. This is usually quite easy to do, with one exception. Serializing results is tricky because rust error types are not serializable by default. + +If you have your own custom error type, you can of course try to make it serializable. +For existing error types like io::Error, you can write a serializable wrapper - an io error consists of a Kind and a message, both of which are serializable. + +And for other errors out of your control, there is the [serde-error](https://docs.rs/serde-error/latest/serde_error/) crate that makes it easy to capture useful information from existing errors and serialize them. + + +My recommendation is to start with anyhow and serde_error, and only come up with nice concrete error types using thiserror or snafu once your design settles down and you know the different possible error cases by heart. Starting too early with complex concrete error types can slow down the development process a lot. + + +## Stream termination + +If you are reading from a remote source, and there is a problem with the connection, you will immediately notice because the call to `recv().await` will fail with a [RecvError::Io](https://docs.rs/irpc/0.5.0/irpc/channel/enum.RecvError.html). If the stream has finished nominally, you will get an `Ok(None)`. + +But what about writing? E.g. you got a task that performs an expensive computation and writes updates to the remote in regular intervals. You will only detect that the remote side is gone once you write, so if you write infrequently you will perform an expensive computation despite the remote side no longer being available or interested. + +To solve this, an irpc Sender has a [closed](https://docs.rs/irpc/0.5.0/irpc/channel/mpsc/enum.Sender.html#method.closed) function that you can use to detect the remote closing without having to send a message. This wraps [tokio::sync::mpsc::Sender::closed](https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Sender.html#method.closed) for local streams and [quinn::SendStream::stopped](https://docs.rs/iroh-quinn/latest/iroh_quinn/struct.SendStream.html#method.stopped) for remote streams. + ## What if you don't want rpc over iroh-quinn channels? -If you integrate iroh protocols into an existing application, it could be that you already have a rpc system that you are happy with, like grpc or json-rpc over websockets. +If you integrate iroh protocols into an existing application, it could be that you already have a rpc system that you are happy with, like [grpc](https://grpc.io/) or [json-rpc](https://www.jsonrpc.org/). -In that case, the cross process facilities of irpc will not be useful for you. But crates that use irpc will always have a very cleanly defined protocol consisting of a *serializable* request enum and serializable update and response types. Piping these messages over a different rpc transport is relatively easy. +In that case, the cross process facilities of irpc will *not* be useful for you. But crates that use irpc will always have a very cleanly defined protocol consisting of a *serializable* request enum and serializable update and response types. Piping these messages over a different rpc transport is relatively easy. -When used purely in-memory, irpc is extremely lightweight when it comes to [dependencies](https://github.com/n0-computer/irpc/blob/5cc624832cfed2653a20442851c203935039d6bc/Cargo.toml#L15). Only serde, tokio, tokio-util and thiserror, all of which you probably have in your dependency tree anyway if you write async rust. +When used purely in-memory, irpc is extremely lightweight when it comes to [dependencies](https://github.com/n0-computer/irpc/blob/5cc624832cfed2653a20442851c203935039d6bc/Cargo.toml#L15). Only serde, tokio, tokio-util and thiserror, all of which you probably have in your dependency tree anyway if you write async rust. (we might switch to [snafu] in the future or manually write the error boilerplate to avoid the dependency). ## Try it out @@ -323,3 +344,4 @@ Because of this extensive experience, we are confident that `irpc` is a good sol [SendStream]: https://docs.rs/iroh-quinn/latest/iroh_quinn/struct.SendStream.html [Stream]: https://docs.rs/futures/latest/futures/prelude/trait.Stream.html [Sink]: https://docs.rs/futures/latest/futures/sink/trait.Sink.html +[snafu]: https://docs.rs/snafu/latest/snafu/ From 258524bd5367861b41bc1489b6f779279b766a60 Mon Sep 17 00:00:00 2001 From: Ruediger Klaehn Date: Fri, 11 Jul 2025 16:18:24 +0300 Subject: [PATCH 08/12] Add link to our error util --- src/app/blog/irpc/page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/blog/irpc/page.mdx b/src/app/blog/irpc/page.mdx index 7953e668..b868a409 100644 --- a/src/app/blog/irpc/page.mdx +++ b/src/app/blog/irpc/page.mdx @@ -308,7 +308,7 @@ enum SetUpdate { All irpc requests, updates and responses need to be serializable. This is usually quite easy to do, with one exception. Serializing results is tricky because rust error types are not serializable by default. If you have your own custom error type, you can of course try to make it serializable. -For existing error types like io::Error, you can write a serializable wrapper - an io error consists of a Kind and a message, both of which are serializable. +For existing error types like io::Error, you can write a custom serializer to be used with [`#[serde(with...)]`](https://serde.rs/field-attrs.html#with). This is how we deal with errors [in iroh-blobs](https://github.com/n0-computer/iroh-blobs/blob/bc61e8e9419256070968d8ce90cc5d1ce6db0c10/src/util.rs#L10). And for other errors out of your control, there is the [serde-error](https://docs.rs/serde-error/latest/serde_error/) crate that makes it easy to capture useful information from existing errors and serialize them. From 39b503b9c8ea8cb0acf4c0ab7658e7ec41a557de Mon Sep 17 00:00:00 2001 From: Ruediger Klaehn Date: Mon, 14 Jul 2025 11:14:46 +0300 Subject: [PATCH 09/12] Update date --- src/app/blog/irpc/page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/blog/irpc/page.mdx b/src/app/blog/irpc/page.mdx index b868a409..6d964da0 100644 --- a/src/app/blog/irpc/page.mdx +++ b/src/app/blog/irpc/page.mdx @@ -4,7 +4,7 @@ import {ThemeImage} from '@/components/ThemeImage' export const post = { draft: false, author: 'Rüdiger Klaehn', - date: '2025-07-04', + date: '2025-07-14', title: 'IRPC', description: "A lightweight rpc crate for iroh protocols", From 3ced88b39724e27a923c7373e3684327e46cd1d8 Mon Sep 17 00:00:00 2001 From: Ruediger Klaehn Date: Mon, 21 Jul 2025 21:05:16 +0300 Subject: [PATCH 10/12] Explicitly import the irpc channel mod so people don't think it is the tokio channels. --- src/app/blog/irpc/page.mdx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/blog/irpc/page.mdx b/src/app/blog/irpc/page.mdx index 6d964da0..c7cde500 100644 --- a/src/app/blog/irpc/page.mdx +++ b/src/app/blog/irpc/page.mdx @@ -229,6 +229,8 @@ The service definition contains just what is absolutely needed. For each request The `rpc_requests` macro will store this information and also create the `RequestWithChannels` enum that adds the appropriate channels for each request type. It will also generate a number of `From`-conversions to make working with the requests more pleasant. ```rust +use irpc::channel::oneshot; + struct KvService {} impl Service for KvStoreService {} @@ -258,6 +260,8 @@ The `rpc` method on `irpc::Client` will only be available for messages where the Ok, that's pretty nice. But then pure rpc requests are also pretty simple. What about more complex requests? Let's look at a very simple example of a bidirectional streaming rpc request. An echo request gets a stream of updates (strings) and just echoes them back, until the update stream stops. ```rust +use irpc::channel::mpsc; + struct EchoService {} impl Service for EchoService {} @@ -289,6 +293,8 @@ The client fn can then add helper functions that transform these two handles for For stream based rpc calls, there is an issue you should be aware of. The quinn [SendStream] will send a finish message when dropped. So if you have a finite stream, you might want to have an explicit end marker that you send before dropping the sender to allow the remote side to distinguish between successful termination and abnormal termination. E.g. the `SetFromStrean` request from above should look like this, and you should explicitly send a `Done` request after the last item. ```rust +use irpc::channel::{oneshot, mpsc}; + #[rpc_requests(KvService, message = RequestWithChannels)] enum Request { ... From c4fdd8a460eb5aaacb7e3b4efd48570ade3fe727 Mon Sep 17 00:00:00 2001 From: Ruediger Klaehn Date: Mon, 21 Jul 2025 21:45:38 +0300 Subject: [PATCH 11/12] update publishing date --- src/app/blog/irpc/page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/blog/irpc/page.mdx b/src/app/blog/irpc/page.mdx index c7cde500..94339f29 100644 --- a/src/app/blog/irpc/page.mdx +++ b/src/app/blog/irpc/page.mdx @@ -4,7 +4,7 @@ import {ThemeImage} from '@/components/ThemeImage' export const post = { draft: false, author: 'Rüdiger Klaehn', - date: '2025-07-14', + date: '2025-07-21', title: 'IRPC', description: "A lightweight rpc crate for iroh protocols", From 9ef292a325828c731d8b178bcc784a89c1513c2b Mon Sep 17 00:00:00 2001 From: Ruediger Klaehn Date: Mon, 21 Jul 2025 21:51:05 +0300 Subject: [PATCH 12/12] Add links to docs.rs --- src/app/blog/irpc/page.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/blog/irpc/page.mdx b/src/app/blog/irpc/page.mdx index 94339f29..70af2349 100644 --- a/src/app/blog/irpc/page.mdx +++ b/src/app/blog/irpc/page.mdx @@ -29,7 +29,7 @@ export const metadata = { export default (props) => -# IRPC - a lightweight rpc crate for iroh connections +# [IRPC] - a lightweight rpc crate for iroh connections When writing async rust code, like you do when writing iroh protocols, you will frequently use message passing to communicate between independent parts of your code. @@ -340,7 +340,7 @@ When used purely in-memory, irpc is extremely lightweight when it comes to [depe ## Try it out -If you are writing an `iroh` protocol and have run into the same tedious boiler plate issues around RPC as we have, give `irpc` a shot. We've spent a lot of time iterating on this issue, in fact this is the second crate we've published that takes a stable at easing the RPC burden. Take a look at the [`quic-rpc`](https://github.com/n0-computer/quic-rpc) if you are curious. +If you are writing an `iroh` protocol and have run into the same tedious boiler plate issues around RPC as we have, give [`irpc`](https://docs.rs/irpc/latest/irpc/) a shot. We've spent a lot of time iterating on this issue, in fact this is the second crate we've published that takes a stable at easing the RPC burden. Take a look at the [`quic-rpc`](https://github.com/n0-computer/quic-rpc) if you are curious. Because of this extensive experience, we are confident that `irpc` is a good solution for doing both in-process, cross-process, and cross-machine RPC, especially if you are building an `iroh` protocol. Check it out and you will see why we at number0 use it for all of the `iroh` protocols that we have created and maintained. @@ -351,3 +351,4 @@ Because of this extensive experience, we are confident that `irpc` is a good sol [Stream]: https://docs.rs/futures/latest/futures/prelude/trait.Stream.html [Sink]: https://docs.rs/futures/latest/futures/sink/trait.Sink.html [snafu]: https://docs.rs/snafu/latest/snafu/ +[irpc]: https://docs.rs/irpc/latest/irpc/ \ No newline at end of file