Part 6: A basic chat server

Published 3 months ago Updated 2 months ago


Now that we know how to deal with multiple clients, we can start to build a simple chat relay server that takes messages from one client, and distributes that message to every other client. We'll start with the code we finished with in the last part and adjust it, so we should start out with:

using System.Net;
using System.Net.Sockets;

var activeSockets = new List<Socket>();

var listener = new Socket(SocketType.Stream, ProtocolType.Tcp);
var endpoint = new IPEndPoint(IPAddress.Any, 12345);
listener.Bind(endpoint);
listener.Listen(5);
activeSockets.Add(listener);

while (true)
{
    var checkSockets = new List<Socket>(activeSockets);
    Socket.Select(checkSockets, null, null, TimeSpan.FromMilliseconds(1));

    foreach (Socket socket in checkSockets)
    {
        if (socket == listener)
        {
            Socket client = socket.Accept();
            activeSockets.Add(client);
        }
        else
        {
            var stream = new NetworkStream(socket);
            var reader = new StreamReader(stream, leaveOpen: true);
            var writer = new StreamWriter(stream, leaveOpen: true);
            
            string? input = reader.ReadLine();
            if (input is null)
            {
                // the connection has been closed
                socket.Close();
                activeSockets.Remove(socket);
                continue;
            }

            writer.WriteLine($"Your message was: {input}");
            writer.Flush();
        }
    }
}

// since the loop is now infinite, this line will never be reached.
// but heuristic analysis may shout at you, so best to keep it anyway
listener.Close();

Before we dive too deep into this, it's probably about time we create some abstractions so that the Main method logic isn't so… a lot. I'm going to create a Listener class which makes the interaction with the underlying socket much simpler. This class will have a field for the listener Socket, a list of all the currently-connected client Sockets, a property which allows us to start and stop the listener, and a Start method which will do most of the actual legwork.

It's also a good idea at this point to include some logging so we can monitor the status of the server and ensure everything is running as we expect.

public class Listener
{
    private readonly Socket _socket = new(SocketType.Stream, ProtocolType.Tcp);
    private readonly List<Socket> _clients = new();

    public bool IsRunning { get; set; }

    public void Start(int port)
    {
        var endpoint = new IPEndPoint(IPAddress.Any, port);
        _socket.Bind(endpoint);
        _socket.Listen();

        Console.WriteLine($"Server is starting on endpoint {endpoint}");

        IsRunning = true;

        while (IsRunning)
        {
            // TODO implement
        }
    }
}

Now we can pretty much copy the logic from the main method into this loop. However, we're going to organise it into separate methods so that we can maintain the code as this series progresses. First we'll create a HandleClient method which will be responsible for reading the line from the client and sending it back. Not a lot has changed here except for the use of the _clients list we just created instead of the activeSockets list we had before, plus the addition of some logging.

private void HandleClient(Socket client)
{
    var stream = new NetworkStream(client);
    var reader = new StreamReader(stream, leaveOpen: true);
    var writer = new StreamWriter(stream, leaveOpen: true);

    string? input = reader.ReadLine();
    if (input is null)
    {
        // the connection has been closed
	    Console.WriteLine($"Client has disconnected");
        client.Close();
        _clients.Remove(client);
        return;
    }

    Console.WriteLine($"Client sent data: {input}");

    writer.WriteLine($"Your message was: {input}");
    writer.Flush();
}

Next, we'll create a method HandleSockets which will take in the “selected” socket list, and determine if we need to accept an incoming connection or if we need to call this new HandleClient method we just created:

private void HandleSockets(IReadOnlyList<Socket> sockets)
{
    foreach (Socket socket in sockets)
    {
        if (socket == _socket)
        {
            // we're checking this very listener socket
            // so accept the incoming connection
            Socket client = socket.Accept();
            _clients.Add(client);

            Console.WriteLine("New client connected");
        }
        else
        {
            // it's one of the clients, so handle the incoming data
            HandleClient(socket);
        }
    }
}

However we're not quite done yet. We've added some logging to indicate that a new client has connected, and when a client sends data, but it would be especially useful to know exactly which client sent data. The most reasonable way to identify a client at the moment is the client's remote endpoint, which is composed of their IP address and a port number. The native function to retrieve this information is called getpeername (man page, Winsock docs), but in .NET we have a simpler abstracted version of that API. All it takes is reading the RemoteEndPoint property on the connected socket. So let's quickly adjust our prints to include this information:

private void HandleSockets(IReadOnlyList<Socket> sockets)
{
    foreach (Socket socket in sockets)
    {
        if (socket == _socket)
        {
            // we're checking this very listener socket
            // so accept the incoming connection
            Socket client = socket.Accept();
            _clients.Add(client);

            Console.WriteLine(<mark>$"New client connected on endpoint {client.RemoteEndPoint}"</mark>;
        }
        else
        {
            // it's one of the clients, so handle the incoming data
            HandleClient(socket);
        }
    }
}

private void HandleClient(Socket client)
{
    var stream = new NetworkStream(client);
    var reader = new StreamReader(stream, leaveOpen: true);
    var writer = new StreamWriter(stream, leaveOpen: true);

    string? input = reader.ReadLine();
    if (input is null)
    {
        // the connection has been closed
	    Console.WriteLine($"Client <mark>on endpoint {client.RemoteEndPoint}</mark> has disconnected");
        client.Close();
        _clients.Remove(client);
        return;
    }

    Console.WriteLine($"Client <mark>on endpoint {client.RemoteEndPoint}</mark> sent data: {input}");

    writer.WriteLine($"Your message was: {input}");
    writer.Flush();
}

And lastly, we can fill in the logic for the main loop. The steps are much the same as before:

  • Create a list of sockets to check
  • Populate it with the client sockets as well as the listener socket
  • Call Socket.Select to determine which sockets are ready
  • Except now, call HandleSockets to handle those sockets which are in fact ready
public void Start(int port)
{
    var endpoint = new IPEndPoint(IPAddress.Any, port);
    _socket.Bind(endpoint);
    _socket.Listen();

    IsRunning = true;

    while (IsRunning)
    {
        <mark>var checkSockets = new List<Socket>(_clients) { _socket };</mark>
        <mark>Socket.Select(checkSockets, null, null, TimeSpan.FromMilliseconds(1));</mark>
        
        <mark>HandleSockets(checkSockets);</mark>
    }
}

Doing this allows us to drastically simplify the Main method, reducing it to just 2 lines of code:

var listener = new Listener();
listener.Start(12345);

After all of this we shouldn't have changed the behaviour of this program aside from the console logging. Let's run the server and connect with Telnet to test it once again:

Hooray, useful information!

Perfect. One thing you might notice at this point is that the server reports both clients to have different port numbers, and neither of them are 12345 which is the port the listener bound to and the port with which the clients connected. The reason for this is a bit unintuitive, so I'll try my best to explain it.

In part 3, we discussed how only one socket can be bound to a given endpoint at any one time. Because our listener socket is bound to port 12345, the client will automatically select a different port, known as an ephemeral port, from the range of available ports on its system (typically 1024 to 65535). This ensures that the client's port does not conflict with the server's listening port. This is the port from which all outbound data are sent from the client to the server.

Tracking client states

Now our server can identify and differentiate each client that connects, but in order to have a functioning chat application we need a way for the clients to identify each other too. This would be in the form of some username or display name. Although we technically could broadcast the endpoint, we obviously shouldn't as that would allow clients to know each others' IP addresses. As you might have guessed, this is a major security risk.

This presents us with a new problem. We need to ask the client for a display name upon connecting, but treat all further messages as actual chat messages, which means we need to track the state of each client to know whether they're in “login mode” or “chat message mode”. For this we can use a simple enum:

public enum ClientState
{
    Login,
    Chat
}

And map each client socket to their respective state value using a dictionary:

private readonly Socket _socket = new(SocketType.Stream, ProtocolType.Tcp);
private readonly List<Socket> _clients = new();
<mark>private readonly Dictionary<Socket, ClientState> _clientStates = new();</mark>

We'll also need a separate dictionary to map each client to their desired username:

private readonly Dictionary<Socket, string> _clientUsernames = new();

When a client connects, their state should be immediately set to Login and we will present them with a prompt to enter their username. We can do this by adding the new client to the dictionary, then opening up a non-disposing NetworkStream and StreamWriter to send them a prompt to enter it, similar to how we do in the HandleClient method. Remember to Flush the writer so that the data actually gets sent!

Socket client = socket.Accept();
_clients.Add(client);

Console.WriteLine($"New client connected on endpoint {client.RemoteEndPoint}");

<mark>_clientStates[client] = ClientState.Login;</mark>

<mark>var stream = new NetworkStream(client);</mark>
<mark>var writer = new StreamWriter(stream, leaveOpen: true);</mark>
<mark>writer.Write("Please enter your username: ");</mark>
<mark>writer.Flush();</mark>

Now to adjust the HandleClient method itself. When the client sends a line of data, we'll determine their current state by polling the dictionary. If they're in the Login state, we'll treat this line as their desired username and store it in the _clientUsernames dictionary. We'll also set their new state to Chat:

if (_clientStates[client] == ClientState.Login)
{
    // client gave their designed username
    _clientUsernames[client] = input;
    _clientStates[client] = ClientState.Chat;
    Console.WriteLine($"{client.RemoteEndPoint} logged in with username '{input}'");
}

If their state is not Login but in fact Chat, this means the line should be treated as a chat message. So we'll take this message and broadcast it to every client which is also in the Chat state. This means we'll need to refactor the logic a bit and use our StreamWriter to wrap the other clients, not the client which sent the data:

else
{
    Console.WriteLine($"Client on endpoint {client.RemoteEndPoint} sent data: {input}");

    foreach (Socket socket in _clients)
    {
        // broadcast the chat message to every client in the Chat state
        if (_clientStates[socket] != ClientState.Chat)
        {
            continue;
        }

        var otherStream = new NetworkStream(socket);
        <mark>var writer = new StreamWriter(otherStream, leaveOpen: true);</mark>
    }
}

Now we simply send a chat message to each socket, prefixing it with the client's username:

<mark>string username = _clientUsernames[client];</mark>
<mark>string message = $"{username}: {input}";</mark>

foreach (Socket socket in _clients)
{
    // broadcast the chat message to every client in the Chat state
    if (_clientStates[socket] != ClientState.Chat)
    {
        continue;
    }


    var otherStream = new NetworkStream(socket);
    var writer = new StreamWriter(otherStream, leaveOpen: true);
    <mark>writer.WriteLine(message);</mark>
    <mark>writer.Flush();</mark>
}

The last thing we need to do is to remove the client sockets from both dictionaries so that when they disconnect, we don't bother keeping track of their state or username anymore since this information will be useless:

string? input = reader.ReadLine();
if (input is null)
{
    // the connection has been closed
    client.Close();
    _clients.Remove(client);
    <mark>_clientStates.Remove(client);</mark>
    <mark>_clientUsernames.Remove(client);</mark>
    return;
}

If at any point you lost track of the code, the complete Listener class should now look something like this:

using System.Net;
using System.Net.Sockets;

namespace NetworkingTutorial;

public class Listener
{
    private readonly Socket _socket = new(SocketType.Stream, ProtocolType.Tcp);
    private readonly List<Socket> _clients = new();
    private readonly Dictionary<Socket, ClientState> _clientStates = new();
    private readonly Dictionary<Socket, string> _clientUsernames = new();

    public bool IsRunning { get; set; }

    public void Start(int port)
    {
        var endpoint = new IPEndPoint(IPAddress.Any, port);
        _socket.Bind(endpoint);
        _socket.Listen();

        Console.WriteLine($"Server is starting on endpoint {endpoint}");

        IsRunning = true;

        while (IsRunning)
        {
            var checkSockets = new List<Socket>(_clients) { _socket };
            Socket.Select(checkSockets, null, null, TimeSpan.FromMilliseconds(1));

            HandleSockets(checkSockets);
        }
    }

    private void HandleSockets(IReadOnlyList<Socket> sockets)
    {
        foreach (Socket socket in sockets)
        {
            if (socket == _socket)
            {
                // we're checking this very listener socket
                // so accept the incoming connection
                Socket client = socket.Accept();
                _clients.Add(client);

                Console.WriteLine($"New client connected on endpoint {client.RemoteEndPoint}");

                _clientStates[client] = ClientState.Login;

                var stream = new NetworkStream(client);
                var writer = new StreamWriter(stream, leaveOpen: true);
                writer.Write("Please enter your username: ");
                writer.Flush();
            }
            else
            {
                // it's one of the clients, so handle the incoming data
                HandleClient(socket);
            }
        }
    }

    private void HandleClient(Socket client)
    {
        var stream = new NetworkStream(client);
        var reader = new StreamReader(stream, leaveOpen: true);

        string? input = reader.ReadLine();
        if (input is null)
        {
            // the connection has been closed
            Console.WriteLine($"Client on endpoint {client.RemoteEndPoint} has disconnected");
            client.Close();
            _clients.Remove(client);
            _clientStates.Remove(client);
            _clientUsernames.Remove(client);
            return;
        }

        if (_clientStates[client] == ClientState.Login)
        {
            // client gave their designed username
            _clientUsernames[client] = input;
            _clientStates[client] = ClientState.Chat;
            Console.WriteLine($"{client.RemoteEndPoint} logged in with username '{input}'");
        }
        else
        {
            Console.WriteLine($"Client on endpoint {client.RemoteEndPoint} sent data: {input}");

            string username = _clientUsernames[client];
            string message = $"{username}: {input}";
            
            foreach (Socket socket in _clients)
            {
                // broadcast the chat message to every client in the Chat state
                if (_clientStates[socket] != ClientState.Chat)
                {
                    continue;
                }

                if (socket == client)
                {
                    // don't relay back to the same user
                    continue;
                }

                var otherStream = new NetworkStream(socket);
                var writer = new StreamWriter(otherStream, leaveOpen: true);
                writer.WriteLine(message);
                writer.Flush();
            }
        }
    }
}

Now time for the exciting part. Let's run the server, spin up a few Telnet instances, and see if our chat messages are sent! If all goes well, you should be able to have a conversation. With yourself 😅.

I'm not crazy, my mother had me tested.

There is one last thing we can do to improve the chat experience, however. Right now, the same chat messages are relayed back to the client which sent them. While it's good to know our server does work properly, it's not necessary in our very simple chat server to repeat the same message back to the sender again. This is an easy fix. When we iterate over the sockets to broadcast the data, we'll just skip the current one:

foreach (Socket socket in _clients)
{
    // broadcast the chat message to every client in the Chat state
    if (_clientStates[socket] != ClientState.Chat)
    {
        continue;
    }

    <mark>if (socket == client)</mark>
    <mark>{</mark>
        <mark>// don't relay back to the same user</mark>
        <mark>continue;</mark>
    <mark>}</mark>

    var otherStream = new NetworkStream(socket);
    var writer = new StreamWriter(otherStream, leaveOpen: true);
    writer.WriteLine(message);
    writer.Flush();
}

I wonder if Alice and Bob will hook up? Find out on next week's episode of The Bachelor.

Well that's just splendid! Our code might be a bit of a mess right now, as our Listener class is doing pretty much all of the work. We'll tidy this up in the next part.