You must be authenticated to submit your files

CSE 102 – Tutorial 11 – A Chat Server

Based on an original tutorial by P.-Y. Strub

Sections tagged with a green marginal line are mandatory.

Sections tagged with an orange marginal line are either more advanced or supplementary, and are therefore considered optional. They will help you deepen your understanding of the material in the course. You may skip these sections on a first pass through the tutorial, and come back to them after finishing the mandatory exercises.

After you are finished with the tutorial, please upload your submission to Moodle by the indicated deadline.

Discussion of the tutorials with friends and classmates is allowed, with acknowledgment, but please read the Moodle page on “Collaboration, plagiarism, and AI”.

There are no automated tests for this tutorial, although you can (and should) still upload your files to the submission server.

Introducing async networking

In this tutorial, we are going to work our way towards implementing a simple chat server based on Transports and Protocols from Python’s asyncio library.

An echo server

We expect you to write your solutions to all of the problems in this section in a file named echo.py and to upload it using the form below. You can resubmit as many times as you like on the submission server, and when you are finished you should make a Moodle submission before the deadline.

Upload form is only available when connected

Let’s start with a very simple echo server, which is a simple program that accepts connections from clients, waits for data from them, and writes it back to the clients. The initial code for our echo server is given below. (We have included type hints to make the code easier to understand and modify.)

# --------------------------------------------------------------------
import asyncio
import sys
from typing import cast

# --------------------------------------------------------------------
class EchoServerClientProtocol(asyncio.Protocol):

    def connection_made(self, transport : asyncio.BaseTransport) -> None:
        peername = transport.get_extra_info('peername')
        print('Connection from {}'.format(peername))
        self.transport = cast(asyncio.Transport, transport)

    def data_received(self, data : bytes) -> None:
        # convert the binary data received into a string
        message : str = data.decode()
        print('Data received: {!r}'.format(message))

        # send the same data back
        print('Send: {!r}'.format(message))
        self.transport.write(data)

        print('Close the client socket')
        self.transport.close()

# --------------------------------------------------------------------
if sys.version_info < (3, 10):
    loop = asyncio.get_event_loop()
else:
    try:
        loop = asyncio.get_running_loop()
    except RuntimeError:
        loop = asyncio.new_event_loop()

    asyncio.set_event_loop(loop)

coro   = loop.create_server(EchoServerClientProtocol, '127.0.0.1', 8888)
server : asyncio.base_events.Server = loop.run_until_complete(coro)

# Serve requests until Ctrl+C is pressed
print('Serving on {}'.format(server.sockets[0].getsockname()))

try:
    loop.run_forever()
except KeyboardInterrupt:
    pass

The asyncio.Protocol relies on the asyncio event loop to dispatch events from the network. To create a TCP server, we recover the asyncio event loop via the asyncio.get_event_loop() function, and then create the server by calling the .create_server() method on the asyncio event loop. This method takes three arguments:

Once the server has been created, we just tell asyncio to run the server, and wait for the completion of its creation. From there, the server is going to accept new connections from clients and serve them.

Let’s go back to the EchoServerClientProtocol class. As we said, this class is reponsible for the communication with clients, and one instance of that class is created per client. The asyncio.Protocol uses a callback-oriented API. This means that asyncio tells your class about (network) events by calling specific methods of this class.

Here, we use two methods: .connection_made() and .data_received().

To run the echo server, simply copy/paste the code above to a file, say echo.py, and launch the server by running python echo.py from the terminal.

Then, to test it as a client, if you have a working telnet client on your machine (it is available on the lab machines and other Linux systems) you can simply open a new terminal and type the following command (localhost is an alias for the IP address 127.0.0.1):

telnet localhost 8888

Alternatively, if you are running macOS you can use nc as a replacement:

nc -c -v localhost 8888

As yet another option, you can install the Python library telnetlib3 (pip3 install telnetlib3), which comes with a telnet client telnetlib3-client.

In Windows, telnet is disabled by default, but can be enabled following the instructions over here. However, be warned that the telnet client behaves differently than usual, since it sends each character typed by the user directly to the server, without waiting for a full line of input. This will make it tricky to test this first version of the echo server since the server will close the client’s connection after receiving a single character. Version 2 of the echo server that you will write below will fix this problem, by properly buffering the input received from the client.

The telnet program opens a connection to your server, and then it forwards to the server all the text that you type into the terminal, and forwards back to you any data it receives from the server. Since the server is just an echo server, if for instance you type the line “bonjour”, telnet sends this text to the echo server, which in turn writes it back to telnet, resulting in the string “bonjour” being displayed in your terminal.

We are going to start with a simple modification of the echo server:

  1. Modify the echo server so that it transforms the received string to uppercase letters before sending it back to the client. (For instance, if the client sends the line “bonjour” then the server should respond with the line “BONJOUR”.) Keep in mind that the data is sent back to the user in binary format. So, after you transform the underlying string message, you will have to encode it again by calling the encode() method.

As previously mentioned, the code for the echo server above makes the assumption that all the data from the client is received on the first call to .data_received(). However, in practice the data may actually only arrive in chunks — for example because the client is sending a lot of data, or because of some networking latency issues. In that case, the .data_received() method is going to be called several times. As such, when developing a client/server protocol, one must design the communication formats so that it is possible for the peers to know when they received all the data. In this tutorial, we are only going to work with text-oriented protocols: when a peer wants to send some data, it sends it using some ASCII format that must fit in one line of text. Hence, a peer knows that it fully received the data when it sees a newline. The official telnet protocol specifies that lines should always end in a “carriage return line feed” sequence (\r\n), but since not all telnet clients properly implement that our protocol will only require a LF ('\n').

  1. Further modify the echo server so that it buffers the data sent by the client until it receives a full line marked by a '\n' character, before sending the line (converted to uppercase) back to the client. Hint: You can add new data attributes to the EchoServerClientProtocol class for storing the partially received data.

    To test the server, try connecting via telnet as before, but now typing the escape character Ctrl-D after entering some text, then entering some more text and typing Ctrl-D again, and so on, eventually ending the line by pressing Enter. For example, a client might have an interaction like the transcript below (where “^D” stands for Ctrl-D, and “^J” stands for Enter):

    $ telnet localhost 8888
    Hi,^DI um^D have to tell you something!^J
    HI,I UM HAVE TO TELL YOU SOMETHING!

    On the side of the server, the log of its interaction with the client may look something like this:

    Connection from ('127.0.0.1', 43632)
    Data received: 'Hi,'
    Current buffer: HI,
    Data received: 'I um'
    Current buffer: HI,I UM
    Data received: ' have to tell you something!\r\n'
    Current buffer: HI,I UM HAVE TO TELL YOU SOMETHING!
    Send: 'HI,I UM HAVE TO TELL YOU SOMETHING!\r\n'
    Close the client socket

Another flaw of our server is that it closes the connection as soon as it receives a line of data. Instead, we’d like the server to continue waiting for new data, echoing lines one-by-one as soon as they are received, until the client closes the connection.

  1. Further modify the echo server to keep echoing the clients lines one-by-one until the client closes the connection. Although you do not need to do anything on the side of the echo server when the client leaves, you could for example print a message that the connection has been closed. To that end, you can add an override for the connection_lost method (see also below).

A simple chat server

We expect you to write your solutions to all of the problems in this section in a file named chat.py and to upload it using the form below. You can resubmit as many times as you like on the submission server, and when you are finished you should make a Moodle submission before the deadline.

Upload form is only available when connected

We are now moving to our chat server. In its simplest form, a chat server is just a super echo server: it receives data from clients and echos it back to them. The only difference is that, instead of sending the data back to the sending client, it echos the data to all the other clients.

For that purpose, we are going to modify the echo server so that all the EchoServerClientProtocol instances share a global state that can be used to store some common data shared by all the clients. Since the intention is to implement a chat server, we’ll also take the opportunity to rename it ChatServerClientProtocol:

# --------------------------------------------------------------------
import asyncio
import sys

# --------------------------------------------------------------------
class ChatServerState:
    pass

# --------------------------------------------------------------------
class ChatServerClientProtocol(asyncio.Protocol):
    def __init__(self, state : ChatServerState):
        self.state = state

    def connection_made(self, transport : asyncio.BaseTransport) -> None:
        # register the client in self.state
        # note that `transport` can be used as a key in a dictionary
        pass

    def connection_lost(self, exc : Exception | None) -> None:
        # unregister the client from self.state
        pass

    def data_received(self, data : bytes) -> None:
        # buffer the received data until a full line is received
        # then, forward that full line to all clients
        pass

# --------------------------------------------------------------------
if sys.version_info < (3, 10):
    loop = asyncio.get_event_loop()
else:
    try:
        loop = asyncio.get_running_loop()
    except RuntimeError:
        loop = asyncio.new_event_loop()

    asyncio.set_event_loop(loop)

state = ChatServerState()

def create_protocol() -> ChatServerClientProtocol:
    global state
    return ChatServerClientProtocol(state)

coro   = loop.create_server(create_protocol, '127.0.0.1', 8888)
server = loop.run_until_complete(coro)

# Serve requests until Ctrl+C is pressed
print('Serving on {}'.format(server.sockets[0].getsockname()))

try:
    loop.run_forever()
except KeyboardInterrupt:
    pass

In the new program, a ChatServerClientProtocol instance has a new data attribute state (of type ChatServerState) that is shared among all the clients. When a client connects, we simply register the new transport into that global state, so that it is then possible to reach all clients’ transport from the methods. Relative to the original version of the echo server, we also added a method .connection_lost() that is called when a client disconnects. We are going to use that callback to unregister clients when they leave the chat server.

  1. Implement the three callbacks of ChatServerClientProtocol to implement a super-echo server that forwards the data it receives to all clients. Some hints are given in comments. For testing your server, you can run several instances of telnet.

From that point, you can start chatting with people that connect to your server. However, there is nothing in the protocol that lets you distinguish messages coming from different clients. We would like to associate each client with a nickname, and to that end we need to make the protocol a bit more sophisticated. When a client connects, it should send first a line containing its nickname, which must be an alphanumerical string. During this period, the client does not receive any message, and if the client tries to use a nickname that is already in use, the server simply closes the connection. Once a valid nickname has been registered, the client starts receiving chat messages, which moreover are always prefixed by the sender’s nickname.

  1. Modify the chat server to implement the nickname protocol.

Since now all clients have a unique identity, it is possible to tag messages for delivery to a specific target user. For that, we are going to extend our protocol again: when the server receives a line of the form:

#nick message...

then it searches for a client whose identity is nick and sends the message (privately) to that client. If the nickname does not exist, the server simply sends some error message to the sender.

Additionally, we would like for clients to be able to see who is online with the command

!users

which tells the server to send a space-separated list of all the client nicknames.

  1. Implement the private message & clients listing protocol.

We may like to add chat rooms to our server. A chat room is uniquely defined by a name, and users can freely enter/leave chatrooms. When in a chatroom, a client can send a message to it and all the clients in that chatroom will receive that message (prefixed with the chatroom name). It is also possible to get a listing of all the chatrooms and of all the users present in a given chatroom.

We add the following commands to the protocol and ask you to (optionally) implement them:

  • @roomname message: send the message message to the chatroom roomname. If the client is not in the chatroom or if the chatroom does not exist, an error message is returned.

  • !enter roomname: enter the chatroom roomname - the chatroom is created if it does not exist. Does nothing if the client is already part of that chatroom.

  • !leave roomname: leave the chatroom roomname - the chatroom is destroyed if no clients remain in that chatroom. Does nothing if the client is not part of that chatroom.

  • !rooms: send a space-separated list of all the chatrooms.

  • !members roomname: send a space-separated list of all the users of a given chatroom. Send an error message if the chatroom does not exists.

You are also free to extend the protocol with other commands, of course! Please just document them.