Lab 02: TCP Socket Programming and Concurrent Servers

Goals

Credits

The material developed for this lab was developed from various sources. We acknowledge the direct and indirect contributions by Prof. Felipe Perrone (Bucknell University), Prof. Xiannong Meng (Bucknell University), and Prof. Phil Kearns (The College of William & Mary). Also, some of this lab is based on material from Unix Network Programming Volume 1, by W. Richard Stevens, Bill Fenner, and Andrew M. Rudoff (Addison Wesley, 2004).

Permission to reuse this material in parts or in its entirety is granted provided that the credits note is not removed. Additional students files associated with this lab, as well as any existing solutions can be provided upon request by e-mail to perrone[at]bucknell[dot]edu.

TCP Sockets

You have learned in CSCI 315 that the Unix pipe is a construct for interconnecting two processes that executed on the same host. Unix pipes follow the byte stream service model, meaning that you work with them by pushing bytes in on thewrite end and pulling bytes out from the read end. Since access to pipes is provided via Unix file descriptors, the programmer can use the same file read(2) and write(2) system calls to operate on pipes.

The concept of a TCP socket is very similar to that of a pipe. The most fundamental difference is that TCP sockets serve to interconnect two processes that execute on arbitrary machines. Whether the two processes execute on the same host or on networked hosts across the world from each other, the set up and operations on the sockets are the same.

You should think of a socket as a communication endpoint. If a socket interconnects processes on arbitrary hosts on the Internet, the first thing that should occur to you is that sockets must be related to Internet addresses. When we say Internet address, it might occur to you that were somehow referring to IP addresses, which we use to pinpoint hosts on the Internet. An IP address, however, can only identify a host, not an application process within that particular host. If you need to pinpoint a specific application process within a host, you need to extend this concept of address to the pair <IP address, port number>, where port number serves to identify an application within a host. This mapping of application to port number doesn't happen by magic, of course. An application must bind to a port number within a given host and it must choose a port number that is not used by the system for a standard service. Take a look at the text file /etc/services to find a large number of well-known port numbers that are used by standard applications. (Though all these port numbers are reserved, not all servers run all services.) The port numbers you use should in your own application programs never conflict with these. In fact, you should be using port numbers in user space, so the instructor will assign each student unique port numbers to use in their programs for this class so that your applications will conflict with neither system services that already exist, nor applications your fellow students will write.

In this lab you will work with a pair of programs which implement the client/server paradigm. The server will be a type of program known as daemon, which performs the following tasks on an infinite loop: wait for a request to arrive, process the request, and send back a response. The client will be a program that crafts a request, sends it to the server, receives and processes the response, and then terminates.

The basic design pattern for client/server applications based on TCP sockets is illustrated in the figure below. This figure shows the sequence of calls to functions in the socket library that are appropriate for the client process and for the server process. Take the time to read the manual pages for the library calls that are new to you (that is, connect, listen, and accept). TCP sockets implements a high level abstraction that gives the programmer a communication channel across networked hosts that is reliable and order-preserving following the same byte-stream service model of Unix pipes.

Next, take another look at the client/server pair given to you in files fortune-client.c and fortune-server.c. In these programs you will notice that we read from and write to sockets with the same read and write system calls that we would use for file I/O and pipes. Therein lies a danger, however. You will be asked to execute these programs to ask for and receive a plain text fortune cookie. You will observe that when you call read or write you may, respectively, either input or output fewer bytes than were requested. What causes this behavior is the fact that when these calls are made buffer limits may be reached for this socket inside the kernel. Whenever this happens, you will need to invoke read(2) or write(2) repeatedly to complete reading or writing the remaining bytes desired.

When you know the exact number of bytes expected in a reading or writing call, you should have your program use the provided readn() and writen() function. They guarantee that your requests for input or output don't ever return with a short count. Read the implementations of these functions and understand how they work. If you don't know the number of bytes in reading, for example, the client wouldn't know the length of the fortune cookie it will be about to receive, you can have the writing party, e.g., the server which is sending the cookie to the client, to send an integer telling the receiving party how many bytes to expect first, then actually send the content, using the following pattern.

/* Code for the sender: */
/* The sender first sends the expected number of bytes to the receiver */
char *cookie = "hello world!";
int  expected_length = strlen(cookie);
bytes_sent = writen(sockID, &expected_length, sizeof(int));

/* The sender then sends the actual content */
bytes_sent = writen(sockID, cookie, expected_length);
...

/* Code for the receiver: */
/* The receiver first reads how long the incoming message will be */
int expected_length;
bytes_read = readn(sockID, &expected_length, sizeof(int));
char cookie[expected_length + 1];

/* The receiver then reads the actual file name */
bytes_read = readn(sockID, cookie, expected_length);
cookie[expected_length] = 0; // terminate the string

Concurrent Servers

The code you are getting for this lab contains a naive design for a client/server application that imposes a severe restriction: all requests to the server are processed sequentially, in the order of their arrival. That means, a long request (or an interactive service such as chat) which arrives first will block the execution of a short request that may take a much smaller fraction of time to complete. In order to guarantee that requests are not forced to wait indefinitely for the completion of previous requests and keep the response time of your server down, you need to resort to concurrency.

The first concurrent server design we propose is based on operating system processes created with the fork() system call. The basic design pattern for such a server is outlined below:

#define LISTENQ 8

pid_t  pid;
int listenfd, connfd;

listenfd = socket( ... );  // fill in sockaddr_in with server's well-known port
bind((listenfd, ... );
listen(listenfd, LISTENQ);

for (;;) {
    connfd = accept(listenfd, ... ); // probably blocks here
    if ((pid = fork()) == 0) {
        close(listenfd);     // child closes listening socket descriptor
        process_request(connfd);
        close(connfd);       // done serving this client
        exit(0);             // child terminates successfully
     } else  {
        close(connfd);       // parent process closes 'connfd'
     }
}
close(listenfd);

Note that in order to use this design pattern, you need to encapsulate the entire handling of a service request in a function, which must be defined as:

void process_request(int fd);

The general idea of this pattern is that the server process listens for client requests on a socket bound to a well-known port. Every time a client attempts to connect to your server, the server spawns off a child process to handle the clients request while keeping listening on its well-known port for other potential client requests. The maximum number of connection attempts that can be queued in the listening socket is defined as LISTENQ, but the pattern above does not limit the number of children that can be created to execute concurrently.

An easy way to overwhelm the machine running the server would be to bombard the server with numerous requests that take a long time to process. If the number of concurrent process rises beyond a tolerable level, the host machine will spend most of its time scheduling processes around without getting much work done. (Remember the thrashing phenomenon discussed in the operating systems class?) Future legitimate requests to this server would effectively be denied processing. In order to prevent against this form of denial of service attack, the server should limit the number of children processes executing concurrently.

Setup

All the files you will need to start your work on this lab can be found at: ~cs363/Spring16/student/labs/lab02/. Copy all the files in that directory to your lab02 directory by the following commands.

Please make sure that you use the two assigned port numbers for your lab exercises and programming projects.

Add .gitignore file to your Git environment

Skip to next section if you already set up the .gitignore file.

So far, we have archived all files into our Git repository. Many of the files do not need to be saved, for example, the object files, or back-up files left by an editor. Git can ignore these files when archiving your project. All you need to do is to set up a .gitignore file in your repository.

In this lab, we have created a .gitignore file for you. First copy the suggested source file, name it .gitignore, then revise it as you see fit. Assume you are in your ~/csci363/labs/lab02 directory.

Problem 1: Construct a fortune cookie server and clients

Unix (and Linux) fortune is a game that you simply type fortune at the command line prompt, you will get a random fortune cookie back. Do a web search for "Linux fortune" or "Unix fortune" for some fun information.

In this set of exercises, you will complete a client/server pair of fortune cookie application. The server understands two requests from a client:

Upon receiving a cook request, the server sends a randomly selected quote (cookie) from a collection. The server first sends the length of the cookie as an integer, followed by the actual text of the cookie in two separate messages.

Upon receiving a stat request, the sever sends the usage statistics to the client. The usage statistics includes the number of times that the server sends the cookies, and the most popular piece of cookies sent so far. Note that the response from the server for the stat request need to have three pieces of information, an integer representing the number of cookies sent, an integer indicating the length of most popular cookie, and the actual text of the most popular cookie.

We will develop two separate client programs for this exercise, though the logic of the two is almost identical. The fortune client will simply send a request cook as a text to the server. The client expects first an integer from the server to represent the length of the incoming cookie. The client then reads the cookie from the server and prints it on the screen.

The code given to you in files fortune-client.c and fortune-server.c outlines the fortune cookie application. These two programs are correct in syntax, but not complete yet. You need to do the following work to complete the exercises.

  1. Read the program in fortune-server.c. You will see that the process_request() function is a skeleton. The task of this function is to locate and send a piece of fortune cookie to the client, following the protocol of this fortune application specified above. You will notice that a function named get_cookie() is provided for you in a separate file named fortune.c. In order to use any functions outside a program file, you need to declare them as extern as in the following example.
    extern char * get_cookie(int);
    
    The get_cookie() function takes an integer n as the index and returns the text of the cookie at that index. The index should be a randomly generated number within the index range.

    Note that both the client and server program will have to deal with the memory allocation issue. Make sure you allocate and deallocate memory properly so the programs do not have the memory leak issue. You don't have to worry the functions in the program file cookies.c

    Complete the process_request() function so the server behaves as follows.

    read the request from a client;
    if the request is 'cook'
       generate a random index within the range;
       retrieve the fortune cookie at that index;
       return the text of the fortune cookie to the client;
    close the socket;
    

    Note that the server may allocate a one-time buffer of the maximum buffer size and re-use it. When the server quits working, release this buffer.

  2. Based on the examples we discussed in the lecture (the complete code is available on the course website), revise the fortune-client.c which behaves as follows.

    create a TCP socket;
    make a connection request to the server;
    send a request 'cook' to the server for fortune cookie;
    read back the fortune cookie from the server;
    print it on the screen;
    close the socket;
    
  3. Revised the server so it can respond to the request 'stat.' The behavior of the server upon receiving the 'stat' request is to send the frequency and the actual text of the most popular fortune cookie so far. Doing so requires the server keep track of the cookie index it generates.
  4. Based on the client fortune-client.c, create another client called stats-client.c, which sends to the server a request 'stat' for its internal status (that is, the statistics it keeps) and displays the response on the standard output. The response from the server should include the frequency and the actual text of the most used cookie.
  5. Modify fortune-client.c and fortune-server.c to use the readn and writen functions when appropriate. Note that doing so would require your server sends back the length of the string first.
  6. Test your programs to make sure they work correctly in various conditions.
  7. Create a text file called answers.txt, put your name, the course number, the lab number, and date in the file. Record a session of executing your programs that demonstrates your programs work correctly for both clients.

When you're done with this problem, you need to do:

Problem 2: Add concurrency to your server

Copy your fortune-server.c to a different file named fortune-serverd.c ("d" is for daemon). Keep the file fortune-server.c intact and make changes to fortune-serverd.c in the following exercises.

Revise Makefile to include proper entries for fortune-serverd.c

Using the design pattern for a concurrent server provided to you earlier in this lab, modify your server to spawn off a new child process every time the server receives a request from a client. Make sure to limit the maximum number of concurrent processes your spawn off so that your server is not easy prey to denial-of-service attacks.

When you're done with this problem, you need to do:

Problem 3: Check to make sure no memory leak

We'd like to make sure that there is no memory leak in any application. However, because network servers are running forever, valgrind can't generate reports for the servers. In order to check memory leaks for our server program, we need to set a stop condition for the server.

Add a count to the total number of requests from clients of both kinds, when the number of requests that the server receives reaches 10, quit the server. After revising the server program so it will stop, run valgrind as you did in the previous lab, copy and paste the screen output on the server side to the answers.txt. Label this this part of the answers.txt to be Problem 3: memory leak check

A note on byte manipulation functions

There are two groups of functions for setting bytes in memory, copying them around, or comparing them. One corresponds to Berkeley-derived functions:

#include 
void bzero(void *dest, size_t nbytes);
void bcopy(const void *src, void *dest, size_t nbytes);
int bcmp(const void *ptr1, const void *ptr2, size_t nbytes);

The other set comes from the ANSI C standard and the functions are provided with any system that supports the ANSI C library:

#include 
void *memset(void *dest, int c, size_t len);
void *memcpy(const void *dest, void *src, size_t nbytes);
int memcmp(const void *ptr1, const void *ptr2, size_t nbytes);

It can be argued that the ANSI C standard functions are more portable. (The choice you make for one set or the other shouldn't affect the performance of your applications.) If you happen to switch around from one set of functions to the other, however, be extra careful in observing the type and order of parameters that each of these functions takes and also the data type each function returns. Comparing the functions for copying bytes, for instance, you will notice that source and destination pointers are swapped, what will make a big difference in your code if you mistakenly assume that the order and the semantics of the parameters is the same in both cases.

IMPORTANT

At the end of today's lab and any further programming/testing sessions programs that use fork(), make sure to leave no processes running in the background before you log off. When you are done, make sure to terminate your server process.

Extra credit work

Check the file cookies.c. You will find that the cookies are stored in a one-dimensional array of characters (string). The look-up for i-th cookie is very inefficient. Rewrite the related functions to make the look up faster. The requirement is to keep the APIs unchanged.

Save and submit the revised program in a file named cookies-rev.c