CSE 102 – Tutorial 11 – A Chat Server
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.
Transports are classes provided by asyncio in order to abstract various kinds of communication channels. Once the communication channel is established, a transport is always paired with a protocol instance. The protocol can then call the transport’s methods for various purposes. In this tutorial, we are going to use the TCP transport that allows to create reliable, streamable, point-to-point connections.
Protocols are classes that implement a network protocol. These classes are used in conjunction with transports: the protocol parses incoming data and asks for the writing of outgoing data, while the transport is responsible for the actual I/O and buffering. When subclassing a protocol class, you have to override certain methods, called callbacks, which will be called by the transport on certain events (for example when some data is received).
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.
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:
= transport.get_extra_info('peername')
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
str = data.decode()
message : 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):
= asyncio.get_event_loop()
loop else:
try:
= asyncio.get_running_loop()
loop except RuntimeError:
= asyncio.new_event_loop()
loop
asyncio.set_event_loop(loop)
= loop.create_server(EchoServerClientProtocol, '127.0.0.1', 8888)
coro = loop.run_until_complete(coro)
server : asyncio.base_events.Server
# 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:
a subclass of the
asyncio.Protocol
class. This class will be responsible for handling the communication with the clients, with one instance of the class created per client.the second argument gives the IP address/interface on which your server is going to listen for new connections. By convention,
127.0.0.1
refers to a local address that is not accessible from outside of the computer, while0.0.0.0
will make your server accept connections from anywhere.the third argument is the port on which your server is going to listen. Here we picked 8888 as the default, but you can choose any number between 1024 and 32767. Be aware that two servers cannot listen on the same port for a given IP address/interface, so you may get an error message if you pick a port number that is already being used by another application. (In particular, if you are running Jupyter notebooks you should pick a different port number, since it uses port 8888 by default.)
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()
.
The first one,
.connection_made()
is called when a new client connects (and is called on a freshly created instance of theProtocol
subclass). It takes as argument aasyncio.Transport
instance representing the connection to the client. Thisasyncio.Transport
allows us to get information about the client, to write to the client or to close the connection with the client. Here, we simply use the.get_extra_info()
method to get the remote client address. We also store the transport in a data attribute of ourEchoServerClientProtocol
class.The second method,
.data_received()
is called when some data has been received from the client. The data received so far is given as an argument to the method. Note that the data is in binary format, so we convert it to a string by calling thedecode()
method. (In this simple echo server, we don’t do anything with the decoded string other than log the fact that we received it.)By the very nature of networking, whether the data is buffered, chunked or reassembled depends on the transport. We here make the assumption that we received all the data from the client, and use the stored transport to write it back to the client and close the connection. The data is not directly written to the client but is instead buffered and will be sent later when possible.
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:
- 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'
).
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 theEchoServerClientProtocol
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.
- 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.
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):
= asyncio.get_event_loop()
loop else:
try:
= asyncio.get_running_loop()
loop except RuntimeError:
= asyncio.new_event_loop()
loop
asyncio.set_event_loop(loop)
= ChatServerState()
state
def create_protocol() -> ChatServerClientProtocol:
global state
return ChatServerClientProtocol(state)
= loop.create_server(create_protocol, '127.0.0.1', 8888)
coro = loop.run_until_complete(coro)
server
# 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.
- 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.
- 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.
- 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 messagemessage
to the chatroomroomname
. If the client is not in the chatroom or if the chatroom does not exist, an error message is returned.!enter roomname
: enter the chatroomroomname
- the chatroom is created if it does not exist. Does nothing if the client is already part of that chatroom.!leave roomname
: leave the chatroomroomname
- 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.