Class 6: Chat Server

Thus far, we've built a fairly robust way to send messages as bytes, and to put these tools to use, we'll be building up a fairly primitive chat server that allows for multiple clients to connect and chat to each other in real time.

Expectations and what you'll be building

This course is focused on Rust, not on networking. Therefore, we'll be building up a very primitive example of a chat server that does pretty much what you'd expect it to do: support multiple clients joining, and chatting with each other.

This involves two pieces of software: the client code and the server code. The server is the one doing the connecting; when multiple users want to talk to each other, they'll connect to a central server which is then responsible for relaying messages between them. Server in the real world might do a lot more than this, but this is the basic scope that we'll be aiming for.

Since there are multiple roles going on at once, this also means that the client (you as the user) and the server need to be running different programs. In order to keep this lab simple though, we've already written the client software, and it will be your task to write the server program.

Specification

Before we dive into the details, let's discuss how the server should ideally work.

  • First, the computer that will run the server needs to run the server, which will be done by cargo run on the server or something similar (maybe ./server or something similar).
  • Second, clients should be able to connect to the server, also by doing cargo run. They will first have to enter a name before joining, and once they do, they will be connected and username joined will automatically be broadcasted to all connected clients, including themselves.
  • When a client enters a messages, the server will broadcast it to everybody, prefacing the message with the username that person entered when they connected.
  • When a client leaves (by pressing ctrl + C), the server should broadcast username left to everyone else connected.
  • If the server stops, all connected clients are disconnected with an error.

These are the basic requirements for this server, and we'll be talking about extensions for it later.

With that our of the way, let's talk about the tool we'll actually need to write the server!

Event loops

The task of a server is not strictly linear unlike many programs that we write. At any given moment, we might have to handle someone joining or leaving the chat server, or someone might send a message that we should broadcast to everyone else. The server can never know what it's going to have to handle next since it depends on external factors, and so we need to make it responsive to anything.

In order to keep the server responsive, we'll use what's called an event loop. This is when the computer running the server sits in a loop forever, checking for various events, and then handling them as they occur.

In particular, event loops aren't unique to servers or Rust. Anywhere where you're waiting for one of many things to occurs can be built with an event loop, such as the firmware in your keyboard for detecting key presses, or traffic stop sensors waiting for cars.

Events in our chat server

Our chat server needs to be responsive to several kinds of events:

  • When a new client joins, the server needs to keep track of their name and TCP connection somewhere in its internal state, as well as broadcast that they joined.
  • When a client disconnects, the server needs to recognize this and remove it from its list of connected clients, as well as broadcast that they left.
  • When a client sends a message, the server needs to forward it to all connected clients.

TCP Connections

In computer networking, TCP is a protocol that allows for sending raw bytes over a network such that the bytes arrive at their destination in the order that they were sent in. This seems like an intuitive and obvious preference, but understand that there's a bit of overhead happening under the hood that makes this possible!

Fortunately for us, the Rust standard library two types: std::net::TcpListener and std::net::TcpStream.

Here's all you need to know:

  • The TcpStream type represents a connection to another program, possibly on another computer. It internally uses the underlying operating system and networking hardware to read and write bytes across a network via the Read and Write traits we previously discussed. You can think of it as a magical connection that allows us to communicate over a network. You can create one with TcpStream::connect("some address").
  • The TcpListener type is the thing on the other end that "listens" for when you do TcpStream::connect. To create a connection, the TcpListener binds to a port (like TcpListener::bind("some address")), at which point other programs can TcpStream::connect to that address. Then, the TcpListener calls .accept() and gets a TcpStream in return, which is the other end of the connection. These two streams are now connected and can write and read bytes to and from each other.

Blocking Operations

Following with the nondeterministic nature of our server, we need to ensure that anyone can send a message at any time and it will immediately be broadcasted to everyone else. With the default settings, however, this will not be the case, and this is because of blocking, a core part of IO.

IO stands for "input output", and it basically can refer to anything that a program does that interacts with the outside world, such as reading and writing files, taking user input from the command line, and sending bytes across a TCP stream.

When a program performs IO, like reading waiting for a TCP connection to send bytes across a network, there's nothing it can do except wait. This means that your program is stuck doing nothing until the outside resource responds, and this is called blocking because your program is blocked from doing anything useful.

By default, the TcpStream type is blocking. If we try to read from one and the other side hasn't sent anything to use yet, we'll be stuff waiting for it to send something. To visualize why this is a problem, consider the following event loop:

// The event loop is literally a loop
loop {
    for tcp_stream in all_connections {
        let message = tcp_stream.block_until_read();
        broadcast(message);
    }
}

Here, we would only be able to see messages in order. If we're blocking on waiting for the first person to send a message and they never do, then we'll never even check if any of the other have sent a message, meaning that our server is fundamentally broken.

Instead of waiting for each client to send a message, what we instead want is to check if each client sent a message, which is a nonblocking operation.

In Rust, we can do this by configuring the TcpListener with .set_nonblocking(true).

This means that if we try to read bytes from a TCP stream and there's no bytes to currently read, instead of blocking the program and waiting, an error will be returned instead as part of io::Result<T>.

However, blocking isn't really an error in our situation, it just means that there's no value ready for us at the moment, which is best represented by an Option<T>. To get this, we'll write a function that will take an io::Result<T> and give us back an io::Result<Option<T>>, where if the result was Ok(t) in the first place, it will just return Ok(Some(t)), if it would have blocked, then Ok(None), and any other actual error would still be Err.

#![allow(unused)]
fn main() {
use std::io;

fn nonblocking<T>(res: io::Result<T>) -> io::Result<Option<T>> {
    match res {
        Ok(value) => Ok(Some(value)),
        Err(e) if e.kind() == io::ErrorKind::WouldBlock => Ok(None),
        Err(e) => Err(e),
    }
}
}

This means that whenever we try to accept an incoming connection from our TcpListener within the server, we can wrap the result in this function to help us distinguish between actual errors and when there are no more connections at the moment.