main() function.
The protocol itself works like this:
- The accepting side waits for the connecting side to open a connection.
- Once a connection is established, the accepting side waits for the connecting side to open a bi-directional stream.
- The connecting side transfers some payload on the bi-directional stream first.
- The accepting side reads the payload and transfers it back on the same bi-directional stream.
- Once the connecting side has finished sending, it reads “the echo” back and then closes the connection.
Listening for connections
As established in the [router] and protocol pages, you’ll first need to decide on an “Application-Layer Protocol Negotiation” (ALPN) string. We’ll use “iroh-example/echo/0”:Router API.
spawn function is what starts an accept loop.
As you saw in the quickstart, we would need to call accept on the router’s builder before, to avoid rejecting every incoming connection attempt, though.
The accept function expects two arguments:
- The ALPN we defined for ourselves above and
- something that implements
ProtocolHandler.
Blobs struct from the existing iroh-blobs protocol, which implements ProtocolHandler.
In this example, we’ll build our own struct and implement ProtocolHandler ourselves.
Let’s call this struct Echo.
If we were building a protocol for a database, then this struct would contain a database connection or the database contents directly, so that all connections can access it.
ProtocolHandler for this trait:
We’re using the
n0-future crate for the return type of accept here.
This is just a shorthand for std::pin::Pin<Box<dyn Future<Output = Result<()>> + Send + 'static>> (which is a mouthful!).
This shorthand is also provided by futures-lite, futures-util and many more.
We simply use n0-future as it re-exports all the crates we’ve vetted and commonly use at number 0.accept function is going to get called once an incoming connection with the correct ALPN is established.
Now, we can modify our router so it handles incoming connections with our newly created custom protocol:
Implementing the Accepting Side
At the moment, theEcho::accept function is still stubbed out.
The way it is currently implemented, it would drop the iroh::Connection immediately, causing the connection to close.
Instead, we need to hold on to either the connection or one of its streams for as long as we want to interact with it.
We’ll do that by moving the connection to the future we return from Echo::accept and handling the protocol logic within that future:
tokio::io::copy here to just copy any bytes we receive via recv to the send side of the bi-directional stream.
Before we drop the connection, we briefly wait for connection.closed().
This effectively allows the connecting side to be the side that acknowledges that it received all data.
Remember: Dropping the connection essentially “interrupts” all work on that connection, including sending or retransmitting lost data.
Calling SendStream::finish() only indicates that we’re done sending data, but doesn’t wait for all data to be sent.
Instead, we’ll make the connecting side - as the side that last receives data - indicate proper protocol procedure by being the side to close the connection.
Closing connections properly with QUIC can be quite hard sometimes.
We’ve written about it before, but it trips us up every now and then still.
Implementing the Connecting Side
The connecting side is going to be the mirror image of the accepting side:- An
accept_bicorresponds to anopen_bi, - when data is received, the other side sends data,
- when one side waits for
connection.closed(), the other callsconnection.close().
EndpointAddr as a parameter.
This is the address of the accepting side, so we can use it to tell the Endpoint where in the world to connect to in the endpoint.connect(addr, ALPN) call.
Putting it all together
Now we have both sides of our protocol implemented! The connect side inconnect_side and the accepting side in start_accept_side.
In a simple main function we can start the accepting side and concurrently connect to it before shutting down the accepting side again:
echo.rs example in the iroh repo.
Appendix
No router no problem
The router can make writing code with iroh easier, but it’s not required. If theRouter API is too limited or perhaps too complex for your use case, it’s fairly simple to replace with your own accept loop based on only iroh::Endpoint APIs.
To replace the router accept loop, you need to spawn your own tokio task instead of calling iroh::protocol::RouterBuilder::spawn.
This task then calls iroh::Endpoint::accept in a loop and passes the incoming connections on to the same handler we looked at before.
You also need to make sure to configure the right ALPNs on the endpoint yourself.
Putting it all together, you only need to change the start_accept_side function:
iroh::Endpoint instead of an iroh::protocol::Router.
This means our main function would need to call endpoint.close() instead of router.shutdown(), but otherwise it’s the same.
Note that in this case, you don’t even need to implement the ProtocolHandler trait.
The only reason it exists is to provide an interface between protocols and the Router.
If we’re not using the router, then we could replace our Echo.accept(connection) call above with whatever function we’d like.
We could even inline the whole function call instead.
You can see a version of the echo example completely without using a router or protocol handler trait in the echo-no-router.rs example.
General Guidance
The echo example is a very simple protocol. There’s many ways in which a protocol in practice is going to be more complex. Here’s some advice that might be useful if you write your own protocol:- Re-use connections: The version of the echo protocol above simply closes the connection after having echo-ed one stream.
This is needlessly wasteful, if e.g. you’d want to echo multiple times or periodically.
Instead, you could put a loop around
connection.accept_bi()to accept multiple streams to echo on for the same connection. In practice, protocols often re-use the same connection for performance. Opening a QUIC stream is really cheap, as it doesn’t need extra round-trips for the stream to get established, which is not the case for connections (unless in special circumstances when you’re using the QUIC 0-RTT feature). - Beware: QUIC streams are lazy: Make sure that when you call
connection.open_bi(), you always send first before you receive data. This is because the other side doesn’t even know about a stream unless you send data on the stream first. This property is called “laziness” - as opposed to being “eager”. The other side that accepts the stream will know about it at the same time that it gets the first bits of data. - Closing QUIC connections can be hard: This was already mentioned above, but it’s worth re-iterating.
As a general rule of thumb: The side to last read data should be the side to close a connection.
Also try to always wait for
Endpoint::closebefore dropping your endpoint, as that’s required to make connections close gracefully. For everything else, feel free to read our blog post about closing connections.
We hope the above helps you write your own iroh protocol. Should you do so, we’d love you to share your new protocol in the iroh discord! Have fun.