Lab 1: Introduction to Low-Level Network Programming in Python

ENSE 472 - Digital Networks - Laboratory

University of Regina - Engineering and Applied Science - Software Systems Engineering

Lab Instructor: Adam Tilson


The purpose of this lab is to:

  • Have a quick primer on creating and running python scripts
  • Understand inter-process communication
  • Understand client-server architecture
  • Understand protocols
  • Low level vs. high level APIs
  • Understand sockets
  • Understanding asynchronous servers and threading

Computer running Windows, MacOS or Linux, with an Intel or AMD-based processor (x86 or x86-64) with administrator privileges

  • In this lab we will be working in Python 3. You will need it installed, which it already is on the lab machines.

In this lab, we will investigate network programming by creating a simple chat application in Python. To begin, we will look at sockets, low level code, which is more similar to c or c++ interfaces on Unix, though a bit easier to work with. We will examine how network communication works from a top-down approach, starting with the programs, and then moving through the different layers of the network stack, as is needed to understand the problem.

A common issue with network programming is that when there are bugs, it is difficult to assess if it is a programming error or a network error. For this reason, a thorough understanding of the entire network stack is required for Software Systems Engineers.

This lab requires a quick overview of the Python programming language. We will work through the exercises included in ense-472-lab-1-warmup.py to give you a quick overview of the programming language. To run the script, ensure you have Python 3 installed, open a terminal into the corresponding window, then type one of the following:

python ense-472-lab-1-warmup.py

Depending on your installation settings, the command you need to run may differ, (e.g. py, py3). When starting a python terminal, ensure the first line prints python 3. You can also ensure you are on python 3 by using python --version which should list 3.X.X. I will be doing these exercises on python 3.10.X.

Note: if you have already taken ENSE 350, you will recall we used Jupyter Notebook for running python. That will not be approapraite for our network programming, as we will often be running servers which will need to run continuously in a single terminal window.

Network programming is essentially inter-process communication, where one program that you create interacts with another program you (or someone else) created. These programs may be running on the same device, or may be running across the world. Typically, interprocess communication on the same device occurs through multiple threads and shared memory, however, running them across multiple devices will require a more complex approach. This is a complex challenge, and the current solution makes use of a complete and complex software system - the internet.

There are a number of concepts that are worth reviewing to help with this lab:

Client

The client is the device (or application) which initiates a request, which will be handled by a server. These are often (but not always) initiated directly by a user issuing a command.

e.g. a web browser, a time-lookup client (ntp), a dns-lookup client

Server

The server is the device (or application) which responds to requests made by clients. It is typically running all the time, and may run as a service in the background (i.e. a daemon). A client will initiate communication with server, and based on the selected protocol, send and/or recieve data from the server.

e.g. a web server, a dns server (bind), a time server, a game server

As single client may connect to a single server:

Or, multiple clients may connect to a single server:

Additionally, the server and client applications may be running on the same physical device, though this is not required:

Finally, note that a single application may act as a server, or a client, or both, however a single thread will typically only be acting as one or the other!

One of the reasons that a client server model is useful is that it minimizes the total number of connections.

Consider a first person shooter game with 100 players. In a client-server model, we would only need 100 connections - each client to the server. However, if instead we wanted every player to connect to every other player, we would need 99 connections for the first player + 98 more connections for the second player + 97 more connections for the third player, etc… This would be a much more complex system!

Address

An address is a way to uniquely identify which device is initiating a request. There are several different addressing systems: public ipv4, private ipv4, ipv6, MAC, with different benefits and use cases. The simplest network configuration is two devices on a Local Area Network (LAN), with each having a different IP address. Packets will be sent along ethernet, and accepted only by the host with the matching IP address

However, there are also more complex setups, such as a Wide Area Network (WAN) such as the internet, in which a connected series of routers will send packets across multiple networks, and eventually deliver the packet to its destination.

Regardless of the complexity of the network, proper addressing and proper network configuration can reliably deliver packets to their destination.

For now, we will be running multiple clients and a server on the same device. We can access the device we are on using the localhost or loopback address 127.0.0.1.

Port

A port is a number which represents a connection on a device which may be used by a single application. A device has 65,535 total ports. By convention, some of these ports are allocated to particular services, e.g. 80 HTTP, 22 SSH, and 443 HTTPS. However, there are other ports which do not have associated services, and are safe to use for your applications. Typically these start above 1024. Because a port uniquely identifies an application, once a packet is recieved, the computer will then be able to know which program in particular is looking for that information.

e.g. on a server, an HTTPS web server will operate on port 443 e.g. on a client, Firefox may be running 6 different tabs on port 52197, 52198, 52199, 52200, 52205.

  • When the first tab requests a web page, it will query the server (e.g. 142.196.3.7:443), and the returned address will be delivered back to the correct tab (e.g. 123.4.50.67:55657).
    • This is important, otherwise, the returned information (a recipe for cookies) may get delived to the wrong tab (your youtube video on network programming) and thus interrupt your stream!

You may think of addresses like a street adress for an apartment, and a port like a suite number. Together we both learn which device we wish to reach, as well as which application.

Protocols

Protocols are the set of rules that dictate how devices on a network will interact with each other. For example, the TCP protocol favours reliability, wheras the UDP protocol favours speed. Depending on the protocol, a single transmitted message (“Hello World”), represented as bytes, may be broken up into a number of different packets, and even include some packets which are purely control information such as confirmations.

e.g. a very simple protocol we will create will tell the server the length of the message using a fixed number of bytes, followed by delivering the actual message using a variable number of bytes:

Socket

A socket is a bi-directional communication endpoint. Each of the two applications which wish to connect will create a socket, and connect to the other application’s socket. To create the socket we will need an IP Address, a Port and a Protocol. Once the socket has been established, the two devices may send data back and forth between the appliations.

Here is an example of different socket operations in python:

Packet

To make network communication more efficient, data may be split into smaller chunks, called packets. We will investigate this further in following labs.

Thread

A thread is the smallest part of a program which can run independantly. We can think of this as a single function which will be created and run independantly until it is completed. The advantage of threads is that their execution and scheduling is handled by the operating system to ensure that the system remains responsive, and so that other threads can finish in a timely manner. It is very advantageous to run server applications as threads, so that each request may be handled by an independant thread.

The trickiest part of threads is when data is shared between threads - as we cannot ensure the order in which lines of code execute in threads, this can lead to a race condition in some situations, with unpredictable results.

Consider a simple variable num_clients which counts the total number of connected clients, and the following two sequences of operation.

e.g. variable num_clients is shared by two threads client_a_handler and client_b_handler.

Happy case:

num_clients = 0

client_a_handler reads num_clients (0)

client_a_handler increments num_clients (1)

client_a_handler writes num_clients (1)

num_clients = 1

client_b_handler reads num_clients (1)

client_b_handler increments num_clients (2)

client_b_handler writes num_clients (2)

num_clients = 2

Race condition:

num_clients = 0

client_a_handler reads num_clients (0)

client_b_handler reads num_clients (0)

client_a_handler increments num_clients (1)

client_b_handler increments num_clients (1)

client_a_handler writes num_clients (1)

client_b_handler writes num_clients (1)

num_clients = 1

We can avoid this using a lock, or similar mechanisms like a semaphore. This will prevent the second thread from reading the variable until the first thread is done with it.

With semaphores:

num_clients = 0

client_a_handler reads num_clients (0) and locks it

client_b_handler reads num_clients can’t, it’s locked, try again later.

client_a_handler increments num_clients (1)

client_b_handler reads num_clients can’t, it’s locked, try again later.

client_a_handler writes num_clients (1) and unlocks it.

client_b_handler reads num_clients (1) and locks it.

client_b_handler increments num_clients (2)

client_b_handler writes num_clients (2) and unlocks it.

num_clients = 2

Finally, it is sometimes important for your main thread to broadcast a message to each of your sub-threads. We can accomplish this using thread events (signals).

Encoding

Encoding and Decoding is the process of turning a string into a series of bytes. There are a number of different methods for this, such as ASCII or Unicode. ASCII uses only one byte per character, which is very efficient, whereas Unicode (UTF-8) requires one to four bytes per character, which python uses by default.

In this section of the lab, we will look at some examples of low level networking using the socket library. In low-level networking, you have complete control over every byte sent and recieved over the network. (Outside of overhead)

Let’s first look at an example of a client-only application which recieves time data from a centeral server. This is an example of the daytime service, which is used for network debugging:

time_client.py

# the socket library contains low-level network operations
import socket
# when creating a socket class (from the socket library), we need to describe the address type (AF_INET is IPv4), and the socket type. 
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Where we want to connect and on what port
host = "time.nist.gov"
port = 13 #default port for daytime service
# open the connection
sock.connect((host,port))
while True:
    # recieve up to 1024 bytes of data
    data = sock.recv(1024)
    if data:
        # daytime service uses ascii
        data_as_string = data.decode('ascii')
        print (data_as_string)
    else:
        break
# when finished with your sockets, it is important to close them
sock.close()

What if, on the other hand, we wanted to be the time-server? That’s a bit more complicated.

Let’s first create a simple python program which prints the current time to the screen.

import datetime
now = datetime.datetime.now()
now_formatted = now.strftime("%A, %B %d %Y, %H:%M:%S")
print (now_formatted)

Not exactly the same format as the remote timeserver, but this is compliant with RFC867

Okay, now we just need to take this code, and host it inside a server:

time_server.py

import socket
import datetime

# create a socket object
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 

# get local machine name
host = socket.gethostname()                           

port = 8000

# bind to the port
serversocket.bind((host, port))

serversocket.listen()                                  

while True:
    # establish a connection
    clientsocket, addr = serversocket.accept()      

    print("Got a connection from %s" % str(addr))
    now = datetime.datetime.now()
    now_formatted = now.strftime("%A, %B %d %Y, %H:%M:%S")
    now_bytes = now_formatted.encode('ascii')
    clientsocket.send(now_bytes)
    clientsocket.close()

Try running this code - nothing happens. But a server is running. Let’s try to connect to it, by modifying our time client such that:

time_client.py

# the socket library contains low-level network operations
import socket
# when creating a socket class (from the socket library), we need to describe the address type (AF_INET is IPv4), and the socket type. 
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Now we want to connect to our own computer!
host = socket.gethostname()
port = 8000
# open the connection
sock.connect((host,port))
while True:
    # recieve up to 1024 bytes of data
    data = sock.recv(1024)
    if data:
        data_as_string = data.decode('ascii')
        print (data_as_string)
    else:
        break
# when finished with your sockets, it is important to close them
sock.close()

However, it is much better if we handle each connection in an individual thread:

Let’s look at how threading works in the next section.

Recall threads are the mechanism in python that allows you to split a program into small, parallel components. These components then run in parallel, as handled by the CPU. Here is a very simple example of a thread:

import threading

def print_cube(num):
	print("Cube: " + str(num * num * num))

def print_square(num):
	print("Square:" + str(num * num))

if __name__ =="__main__":
	# creating thread
	t1 = threading.Thread(target=print_square, args=(3,))
	t2 = threading.Thread(target=print_cube, args=(3,))

	# starting thread 1
	t1.start()
	# starting thread 2
	t2.start()

	# wait until thread 1 is complete
	t1.join()
	# wait until thread 2 is complete
	t2.join()

	# both threads are done
	print("Done!")

Try running this program several times and watch to the order. Do they always finish in the same order?

Let’s try this instead - instead of returning only the square or cube of the number, let’s count all the way up to the number:

import threading

def print_cubes_up_to(num):
	for i in range(num):
		print("Cube: " + str(i * i * i))


def print_squares_up_to(num):
	for i in range(num):
		print("Square:" + str(i * i))


if __name__ =="__main__":
	# creating thread
	t1 = threading.Thread(target=print_squares_up_to, args=(5,))
	t2 = threading.Thread(target=print_cubes_up_to, args=(5,))

	# starting thread 1
	t1.start()
	# starting thread 2
	t2.start()

	# wait until thread 1 is complete
	t1.join()
	# wait until thread 2 is complete
	t2.join()

	# both threads are done
	print("Done!")

On my machine, it occasionally finishes out of order:

Another example which is worth exploring is that of shared variables and race conditions. Look at the following code:

import threading

# global variable x
x = 0

def increment():
    """
    function to increment global variable x
    """
    global x
    x += 1

def thread_task():
    """
    task for thread
    calls increment function 100000 times.
    """
    for _ in range(100000):
        increment()

def main_task():
    global x
    # setting global variable x as 0
    x = 0

    # creating threads
    t1 = threading.Thread(target=thread_task)
    t2 = threading.Thread(target=thread_task)

    # start threads
    t1.start()
    t2.start()

    # wait until threads finish their job
    t1.join()
    t2.join()

if __name__ == "__main__":
    for i in range(10):
        main_task()
        print(f"Iteration {i} : x = {x}")

Run this code a few times. You should see not all iterations always add up to 20000. This is because a race condition occured, as mentioned in the introduction.

We can avoid this by creating a locking mechanism, which we will pass to each of the threads:

import threading

# global variable x
x = 0

def increment(lock):
    """
    function to increment global variable x
    """
    # thread must wait here for the lock to be open
    with lock:
        # once in here, the lock is locked
        global x
        x += 1
    # once we finish this block, we'll unlock the lock again

def thread_task(lock):
    """
    task for thread
    calls increment function 100000 times.
    """
    for _ in range(100000):
        increment(lock)

def main_task():
    global x
    # setting global variable x as 0
    x = 0

    # create a lock
    lock = threading.Lock()

    # creating threads
    t1 = threading.Thread(target=thread_task, args=(lock,))
    t2 = threading.Thread(target=thread_task, args=(lock,))

    # start threads
    t1.start()
    t2.start()

    # wait until threads finish their job
    t1.join()
    t2.join()

if __name__ == "__main__":
    for i in range(10):
        main_task()
        print("Iteration {0}: x = {1}".format(i,x))

Try running it again - no more race conditions!

With servers, we will have code which spawns a new thread which runs in an infinite loop. We will need to be able to send that code a shutdown signal to stop. Let’s see an example of that:

import threading
import time

def timed_output(name, delay, server_active):
    while server_active.is_set():
        time.sleep(delay)
        print (f"{name}: New Message!")

if __name__ == '__main__':
    server_active = threading.Event()
    server_active.set()

    delay1 = 1
    t1 = threading.Thread(target=timed_output, args=("bob", delay1, server_active))

    delay2 = 2
    t2 = threading.Thread(target=timed_output, args=("paul", delay2, server_active))

    t1.start()
    t2.start()

    try:
        while True:
            time.sleep(.1)
    except KeyboardInterrupt:
        print ("attempting to close threads.")
        server_active.clear()
        t1.join()
        t2.join()
        print ("threads successfully closed")

Using what we have learned, we are going to put together a simple chat application. We will partially complete the application together, and then you will complete the rest as the assignment.

Let’s grab some starting code to start:

chat_server.py

import socket

PORT = 8000
PC_NAME = socket.gethostname()
SERVER_IP = socket.gethostbyname(PC_NAME)
TCP_ADDR = (SERVER_IP, PORT)

if __name__ == "__main__":
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind(TCP_ADDR)
    print ("[STARTING] Server is starting...")
    server.listen()
    print (f"[LISTENING] Server is listening on {SERVER_IP}")
    try:
        while True:
            # this is a blocking command, it will wait for a new connection to the server
            conn, addr = server.accept() 
            print (f"[NEW CONNECTION] {addr} connected.")
            conn.close()
            print (f"[END CONNECTION] {addr} disconnected.")
    except KeyboardInterrupt:
        print ("[SHUTTING DOWN]")
    server.close()

chat_client.py

import socket

PORT = 8000
PC_NAME = socket.gethostname()
SERVER_IP = socket.gethostbyname(PC_NAME)
TCP_ADDR = (SERVER_IP, PORT)

if __name__ == "__main__":
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client.connect(TCP_ADDR)
    client.close()

Let’s modify our application so that the client can send one message before closing, which will be displayed on the server:

chat_server.py

import socket

PORT = 8000
PC_NAME = socket.gethostname()
SERVER_IP = socket.gethostbyname(PC_NAME)
TCP_ADDR = (SERVER_IP, PORT)
FORMAT = 'utf-8'

if __name__ == "__main__":
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind(TCP_ADDR)
    print ("[STARTING] Server is starting...")
    server.listen()
    print (f"[LISTENING] Server is listening on {SERVER_IP}")
    try:
        while True:
            # this is a blocking command, it will wait for a new connection to the server
            conn, addr = server.accept() 
            print (f"[NEW CONNECTION] {addr} connected.")
            message_encoded = conn.recv(1024)
            message = message_encoded.decode(FORMAT)
            print (f"[{addr}] {message}")
            print (f"[END CONNECTION] {addr} disconnected.")
            conn.close()
    except KeyboardInterrupt:
        print ("[SHUTTING DOWN]")
    server.close()

chat_client.py

import socket

PORT = 8000
PC_NAME = socket.gethostname()
SERVER_IP = socket.gethostbyname(PC_NAME)
TCP_ADDR = (SERVER_IP, PORT)
FORMAT = 'utf-8'

def send (sock, message):
    message_encoded = message.encode(FORMAT)
    sock.send(message_encoded)

if __name__ == "__main__":
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client.connect(TCP_ADDR)
    message = input ("enter a message:") 
    send (client, message)
    client.close()

However, only sending one message is not particularly useful. We wish to send many messages. Let’s achieve this using loops. While we are at it, let’s add a special message which signals we are ready to disconnect. This will be part of our protocol.

chat_server.py

import socket

PORT = 8000
PC_NAME = socket.gethostname()
SERVER_IP = socket.gethostbyname(PC_NAME)
TCP_ADDR = (SERVER_IP, PORT)
FORMAT = 'utf-8'
DISCONNECT_MESSAGE = "!Q"

if __name__ == "__main__":
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind(TCP_ADDR)
    print ("[STARTING] Server is starting...")
    server.listen()
    print (f"[LISTENING] Server is listening on {SERVER_IP}")
    try:
        while True:
            # this is a blocking command, it will wait for a new connection to the server
            conn, addr = server.accept() 
            print (f"[NEW CONNECTION] {addr} connected.")
            connected = True
            while connected:
                message_encoded = conn.recv(1024)
                message = message_encoded.decode(FORMAT)
                if message and message != DISCONNECT_MESSAGE:
                    print (f"[{addr}] {message}")
                else:
                    connected = False
            print (f"[END CONNECTION] {addr} disconnected.")
            conn.close()
    except KeyboardInterrupt:
        print ("[SHUTTING DOWN]")
    server.close()

chat_client.py

import socket

PORT = 8000
PC_NAME = socket.gethostname()
SERVER_IP = socket.gethostbyname(PC_NAME)
TCP_ADDR = (SERVER_IP, PORT)
FORMAT = 'utf-8'
DISCONNECT_MESSAGE = "!Q"

def send (sock, message):
    message_encoded = message.encode(FORMAT)
    sock.send(message_encoded)

if __name__ == "__main__":
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client.connect(TCP_ADDR)
    message = input(f"enter message, or enter '{DISCONNECT_MESSAGE}' to diconnect: ")
    while message != DISCONNECT_MESSAGE:
        send (client, message)
        message = input()
    send (client, DISCONNECT_MESSAGE)
    client.close()

Hopefully you can see something wrong with this - our in our server we have a nested while loop in the main function. This means once we recieve our first connection, we will be stuck in the inner loop, and unable to accept more connections until the first connection disconnects. One way around this is to use a new thread for each incoming connection on the server.

We will need one thread to handle each connection, which we will use a threaded function for:

chat_server.py

import socket
import threading

PORT = 8000
PC_NAME = socket.gethostname()
SERVER_IP = socket.gethostbyname(PC_NAME)
TCP_ADDR = (SERVER_IP, PORT)
FORMAT = 'utf-8'
DISCONNECT_MESSAGE = "!Q"

# one instance of this will run for each client in a thread
def handle_client(conn, addr):
    print (f"[NEW CONNECTION] {addr} connected.")
    connected = True
    while connected:
        message_encoded = conn.recv(1024)
        message = message_encoded.decode(FORMAT)
        if message and message != DISCONNECT_MESSAGE:
            print (f"[{addr}] {message}")
        else:
            connected = False
    print (f"[END CONNECTION] {addr} disconnected.")
    conn.close()

if __name__ == "__main__":
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind(TCP_ADDR)
    print ("[STARTING] Server is starting...")
    server.listen()
    print (f"[LISTENING] Server is listening on {SERVER_IP}")
    threads = []
    try:
        while True:
            # this is a blocking command, it will wait for a new connection to the server
            conn, addr = server.accept()
            thread = threading.Thread(target=handle_client, args=(conn, addr))
            thread.start()
            threads.append(thread)
            print (f"[ACTIVE CONNECTIONS] {threading.active_count() - 1}")


    except KeyboardInterrupt:
        print ("[SHUTTING DOWN]")
        print (f"[ACTIVE CONNECTIONS] {threading.active_count() - 1}")
    server.close()

No changes needed in chat_client.py

At this stage, we are successfully sending as many messages as we want to the server. However, if we try to shut down our server with ctrl+c, we still have a thread hanging which will prevent closing. Let’s try to account for this with a signal. Additionally, we will also need to occasionally stop listening to our connection so that we may check if the signal has been set - we will do this by adding a timeout to our connection, which will raise a socket.timeout error every second, which we may use to return to our while loop condition. Finally, let’s not forget to join our threads once they complete!

While we are at it, let’s also modify the client so that it can alternatively be exited with ctrl+c.

chat_server.py

import socket
import threading

PORT = 8000
PC_NAME = socket.gethostname()
SERVER_IP = socket.gethostbyname(PC_NAME)
TCP_ADDR = (SERVER_IP, PORT)
FORMAT = 'utf-8'
DISCONNECT_MESSAGE = "!Q"

# one instance of this will run for each client in a thread
def handle_client(conn, addr, server_active):
    print (f"[NEW CONNECTION] {addr} connected.")
    connected = True
    while server_active.is_set() and connected:
        try:
            message_encoded = conn.recv(1024)
            message = message_encoded.decode(FORMAT)
            if message and message != DISCONNECT_MESSAGE:
                print (f"[{addr}] {message}")
            else:
                connected = False
        except socket.timeout:
            pass
    print (f"[END CONNECTION] {addr} disconnected.")
    print (f"[ACTIVE CONNECTIONS] {threading.active_count() - 2}")
    conn.close()

if __name__ == "__main__":
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind(TCP_ADDR)
    print ("[STARTING] Server is starting...")
    server.listen()
    print (f"[LISTENING] Server is listening on {SERVER_IP}")
    threads = []
    server_active = threading.Event()
    server_active.set()
    try:
        while True:
            # this is a blocking command, it will wait for a new connection to the server
            conn, addr = server.accept()
            conn.settimeout(1)
            thread = threading.Thread(target=handle_client, args=(conn, addr, server_active))
            thread.start()
            threads.append(thread)
            print (f"[ACTIVE CONNECTIONS] {threading.active_count() - 1}")


    except KeyboardInterrupt:
        print ("[SHUTTING DOWN] Attempting to close threads.")
        server_active.clear()
        for thread in threads:
            thread.join()
        print (f"[ACTIVE CONNECTIONS] {threading.active_count() - 1}")
    server.close()

chat_client.py

import socket

PORT = 8000
PC_NAME = socket.gethostname()
SERVER_IP = socket.gethostbyname(PC_NAME)
TCP_ADDR = (SERVER_IP, PORT)
FORMAT = 'utf-8'
DISCONNECT_MESSAGE = "!Q"

def send (sock, message):
    message_encoded = message.encode(FORMAT)
    sock.send(message_encoded)

if __name__ == "__main__":
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client.connect(TCP_ADDR)

    try:
        message = input(f"enter message, or enter '{DISCONNECT_MESSAGE}' to diconnect: ")
        while message != DISCONNECT_MESSAGE:
            send (client, message)
            message = input()
    except KeyboardInterrupt:
        pass
    send (client, DISCONNECT_MESSAGE)
    client.close()

Try creating a few clients, connect them, and then close the server. The server should close down gracefully. However, we are not notifying the clients that we are shutting down, so they will hang, and unfortunately since input() is a blocking command, even if they did get the message, they would not be able to handle it. There is no way to handle this in vanilla python, though several libraries exist which could help us, but that is outside the scope of this lab. We will just need to live with this limitation.

One thing we have been doing so far, which is not great, is the following line:

message_encoded = conn.recv(1024)

What this does is reads every message in the socket buffer up to 1024 bytes. But what if the user wanted to send more than 1024 bytes? We would still have extra message in the buffer after the read, which would be counted as the next transmission. Let’s experiment by changing this value to 8, and then sending a longer message from the client:

To solve this, we need to create a protocol, an agreed set of rules that the server and client will agree to. Let’s make this such that, whenever we send a message, the first 8 bytes will represent the length of the message (padded to 8 bytes), e.g.

12

And then the following transmission will be the message of that length.

hello world!

Once the user types in the message, we will need to figure out how long that message is, turn it into a byte string, and send that to the server, so that it knows how far to read into the buffer to collect the rest of the message:

chat_server.py

import socket
import threading

PORT = 8000
PC_NAME = socket.gethostname()
SERVER_IP = socket.gethostbyname(PC_NAME)
TCP_ADDR = (SERVER_IP, PORT)
FORMAT = 'utf-8'
DISCONNECT_MESSAGE = "!Q"
HEADER_SIZE_IN_BYTES = 8

# one instance of this will run for each client in a thread
def handle_client(conn, addr, server_active):
    print (f"[NEW CONNECTION] {addr} connected.")
    connected = True
    while server_active.is_set() and connected:
        try:
            message_length_encoded = conn.recv(HEADER_SIZE_IN_BYTES)
            message_length_string = message_length_encoded.decode(FORMAT)
            if message_length_string:
                message_length_int = int(message_length_string)
                message_encoded = conn.recv(message_length_int)
                message = message_encoded.decode(FORMAT)
                if message != DISCONNECT_MESSAGE:
                    print (f"[{addr}] {message}")
                else:
                    connected = False
        except socket.timeout:
            pass
    print (f"[END CONNECTION] {addr} disconnected.")
    print (f"[ACTIVE CONNECTIONS] {threading.active_count() - 2}")
    conn.close()

if __name__ == "__main__":
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind(TCP_ADDR)
    print ("[STARTING] Server is starting...")
    server.listen()
    server.settimeout(1)
    print (f"[LISTENING] Server is listening on {SERVER_IP}")
    threads = []
    server_active = threading.Event()
    server_active.set()
    try:
        while True:
            try:
                # this is a blocking command, it will wait for a new connection to the server
                conn, addr = server.accept()
                conn.settimeout(1)
                thread = threading.Thread(target=handle_client, args=(conn, addr, server_active))
                thread.start()
                threads.append(thread)
                print (f"[ACTIVE CONNECTIONS] {threading.active_count() - 1}")
            except socket.timeout:
                pass
    except KeyboardInterrupt:
        print ("[SHUTTING DOWN] Attempting to close threads.")
        server_active.clear()
        for thread in threads:
            thread.join()
        print (f"[ACTIVE CONNECTIONS] {threading.active_count() - 1}")
    server.close()

chat_client.py

import socket

PORT = 8000
PC_NAME = socket.gethostname()
SERVER_IP = socket.gethostbyname(PC_NAME)
TCP_ADDR = (SERVER_IP, PORT)
FORMAT = 'utf-8'
DISCONNECT_MESSAGE = "!Q"
HEADER_SIZE_IN_BYTES = 8

def send (sock, message):
    message_encoded = message.encode(FORMAT)
    message_length = len(message_encoded)
    message_send_length = str(message_length).encode(FORMAT)
    padding_needed = HEADER_SIZE_IN_BYTES - len(message_send_length)
    # this repeats the space byte by padding needed times
    padding = b' ' * padding_needed
    padded_message_send_length = message_send_length + padding
    sock.send(padded_message_send_length)
    sock.send(message_encoded)
    
if __name__ == "__main__":
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client.connect(TCP_ADDR)

    try:
        message = input(f"enter message, or enter '{DISCONNECT_MESSAGE}' to diconnect: ")
        while message != DISCONNECT_MESSAGE:
            send (client, message)
            message = input()
    except KeyboardInterrupt:
        pass
    send (client, DISCONNECT_MESSAGE)
    client.close()

We now have a functioning chat client which can send messages of any size up to 99999999 characters to the server. In the assignment you will expand this to also be able to send messages to other clients!

We have covered many things in this lab (maybe too many things?) but hopefully you can see how all these pieces together can create a low level socket-based client-server architecture.

So far, we have successfully made our chat application send information one way, from the user to the server. We will now add some additional features in three phases:

Phase 1: Echo Service

An echo service runs on a server, and simply collects any message sent to it, and returns it back to the client. Add this functionality to our application. Be sure to print it out on the client side!

Hints:

  • You may wish to copy some of the send code from the client to the server. However, remember the client only has a single connection, whereas the server has multiple. How will you handle this?
  • You may wish to add a thread to the client to handle incoming messages, as the input read code will block code progression in main!
    • Be sure to join this thread if the client disconnects before shutting down!

Example output at phase 1:

Phase 2: Chat Service

In phase 2, modify the application so that when a client sends a message to the server, it is sent to every other connected client except the one who sent it. The sent message should also contain some identifying information, i.e. which client sent it (address and port).

Hints:

  • Recall we have one thread handling receiving information from each client, but we do not need to use it to send the information back to that client - any thread can use any socket, as long as it knows about it.
  • How might you wish to store all of the connections so that they can be accessed by all of the threads?
    • How can you prevent against race conditions for these threads?

Note, for this part, please comment out the echo server part, as we no longer wish to send the message back to the user who sent it.

Example output at phase 2:

Phase 3: Better Chat

In the final part, we will polish up our chat application. Upon entry, a client should choose a username, which is sent to the server, and stored as needed. This should be used in chat in place of an address. Additionally, when a user enters or leaves the chat, each other connected client should recieve this information.

Hint:

  • You may wish to have some sort of data structure which associates names and connections.
    • If this data structure will be shared among threads, ensure you avoid race conditions!

Submission

You will submit your assignment to URCourses by the due date. Please make a copy of your project after each phase and zip and submit them all so that you may be given partial credit if you don’t make it to the end!

Easiest “daytime” service client in Python? - StackOverflow, [Online]

Python Tutorial: Network Programming - Server & Client A: Basics - 2020, [Online]

Multithreading in Python, [Online]

Closing all threads with a keyboard interrupt [Online]

Tech with Tim. Python Socket Programming Tutorial [Online]. (Note - I based my chat application off of this tutorial. There are some errors with his Network descriptions though!)