< back to home

Quic server in Rust

2025-08-27

Last year, I was working in a server that would "bridge" notebooks with a custom backend service. One of the decisions was to use QUIC instead of your old TCP or UDP friends.

I like to describe QUIC as "TCP implemented on User Space on top of UDP". It establishes a bunch of multiplexed connections on top of UDP and it's suppose to be an improvement over TCP.

This was my first time working with QUIC, so I wanted a playground where I could learn all the details of the protocol without the deadlines. And that is why I put together debra. debra is basically a chat application, where computers can connect to a single backend that handles the routing of the messages. Since QUIC allows us to have bi-directional streams, everything works out of the box. Computer A sends a message to the server to be routed to Computer B. Server finds which stream belongs to the Computer B and sends the message. Since this is a toy, we drop the message if Computer B is not online.

I wanted to play with the protocol, but not implement it from scratch, I decided to use quinn to abstract it away for me (it was the same crate used at work as well).

The code is simple, every time a new connection comes, we get the bidirectional stream. For the receiver, we put it in a tokio task to handle all the new messages. For the sender, we put it in our map, so we later can find it when we need to send a message. We assume the first message the client will send is its id.


pub async fn new_connection(&self, conn: quinn::Incoming) -> Result<()> {
    let connection = conn.await?;

    let stream = connection.accept_bi().await;
    let (sender, mut receiver) = match stream {
        Err(quinn::ConnectionError::ApplicationClosed { .. }) => {
            info!("connection closed");
            return Ok(());
        }
        Err(e) => {
            return Err(e.into());
        }
        Ok(s) => s,
    };


    let bytes = read_bytes(&mut receiver).await?;

    let message =
        root_as_message(&bytes).map_err(|e| anyhow!("failed parsing message: {:?}", e))?;

    if message.message_type() != MessageType::ClientRegistration {
        error!("not expecting message type: {:?}", message.message_type());
        return Err(anyhow!("First Message should be client registration"));
    }

    let id = message.client_id();

    // we just assume something else is setting the ids for the clients
    // and we can trust the clients
    self.clients
        .insert(message.client_id(), Arc::new(Mutex::new(sender)));

    let sender = self.tx.clone();

    tokio::spawn(async move {
        // this will keep reading messages from the stream
        // until an error happens.
        loop {
            match read_bytes(&mut receiver).await {
                Ok(bytes) => {
                    info!("new message from client: {id}");
                    let letter_to = root_as_message(&bytes)
                        .map_err(|e| error!("failed parsing message: {:?}", e))
                        .ok()
                        .map(|m| m.for_client());

                    if let Some(letter_to) = letter_to {
                        // ignore the error for now
                        let _ = sender.send(Letter::Forward(letter_to, bytes)).await;
                    } else {
                        info!("message for nobody");
                    }
                }
                Err(e) => {
                    error!("error while reading message: {:?}", e);
                    // ignore the error for now
                    let _ = sender.send(Letter::Remove(id)).await;
                    break;
                }
            }
        }
    });

    Ok(())
}

In order to send messages we just need to find the right stream:


async fn handle_letters(&self, letter: Letter) -> Option<()> {
    match letter {
        Letter::Remove(id) => {
            self.clients.remove(&id);
        }
        Letter::Forward(letter_to, bytes) => {
            // clones to avoid deadlock due to clashmap
            let stream = self.clients.get(&letter_to)?.clone();
            // spawns a new task to avoid blocking our main loop
            tokio::spawn(async move {
                let mut stream = stream.lock().await;
                // first bytes are always the size
                let _ = stream
                    .write_all(&bytes.len().to_be_bytes())
                    .await
                    .map_err(|e| error!("error while sending message {:?}", e));

                let _ = stream
                    .write_all(&bytes)
                    .await
                    .map_err(|e| error!("error while sending message {:?}", e));

                info!("finished forwarding message");
            });
        }
    }

    Some(())
}

I also built a client for it. The client code is a bit more hacky, since I needed something quick and dirty to test. And as you can imagine, it follows more or less the same steps as the server.