PA 1 - Named Pipes
Class: CSCE-313
Notes:
Introduction
In this assignment you'll write a client program that connects to a server using named pipes and uses the exported API of the server to realize client functionality. The server hosts electrocardiogram (ECG) data points of 15 patients suffering from cardiac diseases. The client communicates with the server to complete two tasks.
- Obtains individual data points from the server.
- Obtains a whole raw file of any size in one or more segments from the server.
The client has to send correctly-formatted messages to the server using a communication protocol defined by the server.
Starter Code
- makefile. This file compiles and builds the source files when you type the make command in the terminal.T1
- FIFORequestChannel.{cpp,h}. These files implement a pipe-based communication channel. The client and server processes use this class to communicate with each other. This class has a read and a write function to receive and send data from/to another process, respectively. The usage of the function is demonstrated in the given client.cpp. **Do not modify this class.**T2
- server.cpp. This file contains the server logic. When compiled with the provided makefile, an executable server is created. Run this executable to start the server. Refer to the server to understand the server protocol and then implement the client functionality based on that. Do not modify this program.
- client.cpp. This file contains the client logic. The starter code can connect to the server using the FIFORequestChannel class; the client sends a sample message to the server and receives a response. When compiled using the provided makefile, an executable client is created. Run this executable to start the client. You will make most of your changes in the client program.
- common.h and common.cpp. These files contain useful classes and functions shared between the server and the client. Classes for different types of messages (e.g., a data message, a file message) are defined here. Do not modify this class.
Server Specification
The client requests a service from the server by sending it an appropriately formatted message through a FIFORequestChannel class. In response, the server executes the correct corresponding functionality, prepares a response, and sends it back to the client through the same channel.
The server is in an loop in handle_process_loop doing the following.
while(1) {
read request from inbound fifo
service the request
write response to outbound fifo
}
There are four message types:
- DATA_MSG,
- FILE_MSG,
- NEWCHANNEL_MSG,
- QUIT_MSG.
The first two correspond to data classes datamsg and filemsg.
There are subtelties to be considered in the request-response protocol between the client and the server. For e.g., if the client writes two requests into the channel in succession, and the server provides a buffer larger than the first request to read a message, it could read the first request and part of the second request. If that happened, the server would have to account for it. Thankfully, our protocol is strictly request-response driven, so the client never sends another request until it receives a response from the server.
3.1 Connecting to the Server
You will see the following in the server's main function.
FIFORequestChannel* control_channel = new FIFORequestChannel("control", FIFORequestChannel::SERVER_SIDE);
The first argument in the channel constructor is the name of the channel, and the second argument is the side (server or client) that is using the channel.
To connect to the server, the client creates an instance of FIFORequestChannel with the same name but with CLIENT_SIDE as the second argument:
FIFORequestChannel chan("control", FIFORequestChannel::CLIENT_SIDE);
The two lines above belong to the FIFORequestChannel class, and set up a communication channel over an OS-provided IPC mechanism called a FIFO or a named pipe. Named pipes are created by the system called mkfifo.
They are used by processes to receive (using the read system call) and send (using the write system call) information to one another. The client uses the cread and cwrite functions appropriately to communicate with the server. For more on “named pipes”, refer to fifo.7.html.
After creating the channel, the server goes into an loop processing client requests. The client and the server can connect with each other using several channels.
3.2 Data Point Requests
The server contains the BIMDC directory which includes 15 files (1.csv-15.csv), one for each patient. The files contain ECG records for a duration of one minute, with a data point every 4ms, resulting in a total of 15,000 data points per file. A particular row (data point) in any of these CSV files is represented using the following format:time(s), ecg, ecg
You will find the request format in common.h as a datamsg. The client requests a data point by constructing a datamsg object and sending this object across the channel through a buffer. A datamsg object is constructed by specifying the following arguments to the client.
- -p patient-id is specified as a number. There are 15 patients in total. The required data type is an int with a value in the range [1,15].
- -t time in seconds. The type is a double with range [0.00,59.996].
- -e ecg-record. The record value is either 1 or 2, indicating which record (ecg1 or ecg2) the client should be sent. The data type is an int.
The message type field MESSAGE_TYPE is implicitly set to the constant DATA_MSG. Both the message type and its possible values are defined in common.h.
The following is an example of requesting ecg2 for patient 10 at time 59.004 from the command line when you run the client.$ ./client -p 10 -t 59.004 -e 2
An appropriate datamsg object constructed would therefore be the following.datamsg dmsg(10, 59.004, 2);In response to a properly formatted data message, the server replies with the ecg value as a double. Your first task is to prepare and send a data message to the server and collect its response.
3.3 File Requests
Let us first understand the role that buffer capacity plays in file requests. If, for example, we transfer a large file of 20 GB in one transaction, the message sent across the channel or the physical memory required will also be 20 GB. That may not be possible given the limit of a messagewrite in a fifo.
To circumvent this, we set the limit of each transfer by the variable called buffercapacity in both client.cpp and server.cpp. This variable defaults to the constant MAX_MESSAGE (256 Bytes) defined in common.h.
The user can change this value by providing the argument -m to any command. In the example below, the buffer capacity is changed to 5000 bytes.$ ./client -m 5000
The change must be done for both the client and server to make it effective (e.g., seeing faster/slower performance). We can request the file in segments through chunks referenced by the corresponding byte number intervals in the following wayT4:
In this particular example when transferring the chunk of data in the range [10000...15000) bytes, our offset is 10,000 and the length is 5,000 bytes. Therefore, instead of requesting the whole file, you may request each portion of the file where the bytes are in the range [offset...offset + length). As a result, you can allocate a buffer that is only length bytes long but uses multiple packets to transfer a file.
To request a file, you (the client) will need to package the following information in a message.
- Starting offset in the file. The data type is
__int64_tbecause a 32-bit integer is not sufficient to represent large files beyond 4GB. - How many bytes to transfer beginning from the starting offset. The data type is int. To transfer a file larger than a 32-bit integer you must request it in chunks using the offset parameter.
- The name of the file as a NULL-terminated string, relative to the directory BIMDC.
The message type field MESSAGE_TYPE is implicitly set to a constant FILE_MSG. Both the message type and its possible values are defined in common.h. For example, to retrieve 30 bytes from a file at an offset of 100 you would construct a filemsg object:filemsg msg(100,30);
Here, offset is the first parameter and length is the second parameter. You can also set the offset and length by setting msg.offset and msg.length to the desired value. The type filemsg in common.h encodes this information. When sending a message across the channel to the server, we can then send a buffer that contains the filemsg object, and the name of the file we are attempting to transfer (as a NULL-terminated string) following the filemsg object.
The server responds with the appropriate chunk of the contents of the requested file. You won't see a field for the file name, because it is a variable-length field. To use a data type, you need to know the length exactly, which is impossible to determine at compile time. You can just think of the filename as variable-length payload data in the packet that follows the header, which is a filemsg object.
Also, the requested filename is relative to the BIMDC directory. Therefore, to request the file BIMDC/1.csv, the client would put “1.csv” as the filename. The client should store the received files under the received directory and with the same name (i.e., received/1.csv). Furthermore, take into account that you are receiving portions of the file in response to each request. Therefore, you must prepare the file appropriately so that the received chunk of the file is put in the right place.
Consider this case: the client attempts to transfer a file of size 400 bytes, and the buffer capacity is 256 bytes. In our first transfer we would set the offset to 0, and length to 256. In the next transfer, we would have to set the offset to 256 and the length to 144. The client would have to know the size of the file prior to the transfers so it can make appropriate adjustments to the length in the last transfer. Therefore, it must initially send a message to the server asking for the size of the file.
To achieve this, the client should first send a special file message by setting offset and length both to 0. In response, the server just sends back the length of the file as an __int64_t, which is a 64-bit integer and is necessary for files over 4GB in size (i.e., the max number represented by an unsigned 32-bit integer is . From the file length, the client then knows how many transfers it has to request because each transfer is limited to buffercapacity.
The following is an example request for getting the file “10.csv” from the client command line:$ ./client -f 10.csv The argument -f specifies the filename.
3.4 New Channel Creation Request
The client can ask the server to create a new channel for communication. The flag (-c) used to create a new channel can be used with any other command. All communication for that execution will occur over the new channel.
The client sends a special message with the message type set to NEWCHANNEL_MSG. In response, the server creates a new request channel object, and returns the channel name back, which the client uses to join into the same channel. This is shown in the server's process_new_channel function.
The following is an example of a new channel being requested to transfer file “5.csv”:
$ ./client -c -f 5.csv
Your task (2 parts)
The following are your tasks.
4.1 Run the server as a child process (15 pts)
- Run the server process as a child of the client process using fork() and exec() such that you do not need two terminals.
- The outcome is that you open a single terminal, run the client which first runs the server and then connects to it.
- To make sure that the server does not keep running after the client dies, send a QUIT_MSG to the server for each open channel and call the wait(...) function to wait for its end.
It would be beneficial to implement this functionality first so that the autograder works; however, you can run them separately on two different terminals to locally test your client functionality.
#include "common.h"
#include "FIFORequestChannel.h"
#include <unistd.h> // fork, execvp
#include <sys/wait.h> // wait, waitpid
using namespace std;
int main (int argc, char *argv[]) {
int opt;
int p = 1;
double t = 0.0;
int e = 1;
int m = MAX_MESSAGE;
string filename = "";
while ((opt = getopt(argc, argv, "p:t:e:f:m")) != -1) {
switch (opt) {
case 'p':
p = atoi (optarg);
break;
case 't':
t = atof (optarg);
break;
case 'e':
e = atoi (optarg);
break;
case 'f':
filename = optarg;
break;
case 'm':
m = atoi (optarg);
break;
}
}
// Give arguments to the server
// server needs './server', '-m', '<val for -m arg>', 'NULL'
string m_str = to_string(m); // will be coverted to char with .c_str()
char* server_args[] = {(char*) "./server", (char*) "-m", (char*) m_str.c_str(), nullptr};
// fork: create child to run server
pid_t server_pid = fork();
if (server_pid == -1) {
perror("fork");
return 1;
}
// In the child, run execvp using the server arguments
if (server_pid == 0) {
execvp(server_args[0], server_args);
// if execvp fails
perror("execvp");
return 1;
}
// ...
// closing the channel
MESSAGE_TYPE q = QUIT_MSG;
chan.cwrite(&q, sizeof(MESSAGE_TYPE));
// parent waits for server children before exit
int status = 0;
waitpid(server_pid, &status, 0);
return 0;
}
4.2 Requesting Data Points (15 pts)
First, request one data point from the server and display it to stdout by running the client using the following command line format:$ ./client -p <patient no> -t <time in seconds> -e <ecg number>
You should use the Linux function getopt to collect the command line arguments. You cannot scan the input from the standard input using cin or scanf. After retrieving one data point, check that they match.
For collecting the first 1000 data points for a given patient, request them for both ecg1 and ecg2, collect the responses, and put them in a file named x1.csv. Compare the file against the corresponding data points in the original file and check that they match. Use the following command line.$ ./client -p <patient number>
4.3 Requesting Files (50 points)
-
Request a file from the server side using the following command format again using getopt(...) function:
$ ./client -f <file name>The file does not need to be one of the .csv files currently existing in the BIMDC directory. You can put any file in the BIMDC/ directory and request it from the directory. The steps for requesting a file are as follows.-
First, send a file message to get its length.
-
Next, send a series of file messages to get the content of the file.
-
Put the received file under the received/ directory with the same name as the original file.
-
Compare the received file against the original file using the Linux command diff and demonstrate that they are identical.
-
Measure the time for the transfer for different file sizes (you may use the Linux command
$ truncate -s ⟨s⟩ test.binto truncate an existing file test.bin to s bytes, or to create a file with s NULL bytes.Tabulate the time taken to transfer files of various sizes and put the results in the report as a chart. Name this file as report.pdf and include it in the root folder of your submission.
- You need to visualize how your system scales with different file sizes.
- Run timing experiments for various file sizes (e.g., 10KB, 1MB, 10MB, 50MB, 100MB).
- Create a line chart plotting file-size (X-axis) vs. transfer-time (Y-axis).
- Include a data table of your raw measurements.
-
-
Make sure to treat the file as binary because we will use this same program to transfer any type of file (e.g., music files, ppt, and pdf files, which are not necessarily made of ASCII text). Putting the data in an STL string will not work because C++ strings are NULL terminated. To demonstrate that your file transfer is capable of handling binary files, make a large empty file under the BIMDC/ directory using the truncate command (see man pages on how to use truncate), transfer that file, and then compare to make sure that they are identical using the diff command.
-
Experiment with transferring a large file (100MB), and document the required time. What is the main bottleneck here? Submit your answer in the repository in a file answer.txt.
-
Create a 100MB dummy file in your BIMDC/ directory using
truncate -s 100M BIMDC/testfile.bin -
Transfer the file using your client and record the total time taken.
-
Create a plain-text file named answer.txt. This file should be in the root directory of your submission.
-
Include the recorded transfer time and a brief explanation of the primary performance bottleneck for example, request-response latency, context switching, or something else.
-
Creating files with random data
From Kyle Lang, a peer teacher from an earlier version of the class. If you just need files of some size with arbitrary data in it, these should work:
- Using the
ddcommand.
dd if=/dev/zero of=file_name bs=1024k count=10
- This will create a file named
file_namewith a size of ~10MB (1024Kb x10). Theifoption specifies the input file (/dev/zerogenerates zeros),ofspecifies the output file,bssets the block size (1024KB), and count specifies the number of blocks to write.
- Using the truncate command.
truncate -s 10000000 file_name
- This will create a file named
file_namewith a size of exactly 10MB (10000000 bytes) filled with null bytes. The-soption specifies the file size.
- Using the head command.
head -c 10000000 /dev/zero > file_name
- This will create a file named
file_namewith a size of approximately 10MB ( bytes). Theheadcommand reads a specified number of bytes from the input file (/dev/zerogenerates zeros) and writes them to the output file.
Part 1: 20 pts
report.pdf: 10 pts
Part 2: 10 pts
Part 3 implementation: 5 pts, answer.txt: 5 pts
10KB
$ ./client -f testfile_10K.bin
File length is: 10240 bytes
Transfer time: 0.00489574 s
500KB
$ ./client -f testfile_500K.bin
File length is: 512000 bytes
Transfer time: 0.164912 s
1MB
$ ./client -f testfile_1M.bin
File length is: 1048576 bytes
Transfer time: 0.349693 s
10MB
$ ./client -f testfile_10M.bin
File length is: 10485760 bytes
Transfer time: 6.5972 s
25MB
$ ./client -f testfile_25M.bin
File length is: 26214400 bytes
Transfer time: 10.8185 s
50MB
$ ./client -f testfile_50M.bin
File length is: 52428800 bytes
Transfer time: 16.4744 s
75MB
$ ./client -f testfile_75M.bin
File length is: 78643200 bytes
Transfer time: 23.5785 s
100MB
$ ./client -f testfile_100M.bin
File length is: 104857600 bytes
Transfer time: 24.7338 s
4.4 Requesting a New Channel (15 pts)
Ask the server to create a new channel by sending a special NEWCHANNEL_MSG request and joining that channel. Use the command format shown in the example above. After the channel is created, you need to process the request the user passed in through the CLI options.
Starter code:
int opt;
int p = -1;
double t = -1.0;
int e = -1;
int m = MAX_MESSAGE;
bool new_chan = false;
vector<FIFORequestChannel*> channels;
// ... getopt
// ... run server child
// ---------------------- New channel request ----------------------
FIFORequestChannel cont_chan("control", FIFORequestChannel::CLIENT_SIDE);
channels.push_back(&cont_chan); // push the new channel into the vector
// Create a new channel (-c argument)
if (new_chan) {
// Send newchannel request to the server
MESSAGE_TYPE nc = NEWCHANNEL_MSG;
chan.cwrite(&nc, sizeof(MESSAGE_TYPE));
// Create a variable to hold the name of the channel
// cread the response from the server
// call the FIFORequestChannel constructor with the name from the server (use "new" to call dynamically)
// Push the new channel into the vector
}
// We want to use the last channel in our vector to send all of our requests
FIFORequestChannel chan = *(channels.back());
// ... deatapoint requests
// ... file requests
// if necessary, close and delete the new channel
if (new_chan) {
// do your close and deletes.
}
// ... closing channel
Why you need a vector<FIFORequestChannel>*
- Because by the end of the assignment you can have more than one channel (control + new channels, possibly multiple). The spec requires:
- send QUIT_MSG to every open channel
- delete dynamically created channels
- then wait() for the server
- A vector lets you:
- keep track of every channel you opened
- close them all in one loop (no missed QUITs, no leaks)
4.5 Closing Channels (5 pts)
You must also ensure that there are NO open connections at the end and NO temporary files remaining in the directory either. The server should clean up these resources as long as you send QUIT_MSG at the end for the new channels created. The given client.cpp already does this for the control channel.