2020-11-21

Computer Network: Socket Programming

Download experiment code here

1. Experiment Objectives

  • Master the main characteristics and working principles of TCP and UDP protocols

  • Understand the basic concepts and working principles of sockets

  • Implement socket network communication through programming (C/Python/Java)

2. Experiment Code Implementation

2.1 Code Organization Structure

The code for the 3 projects (6 programs) implemented in this experiment are as follows:

  • server_reg.c and client_reg.c: Simple network registration server and client

  • server_check.c and client_check.c: Simple network check-in server and client

  • server_chat.c and client_chat.c: UDP chat room server and client

These codes depend on the following self-written function libraries:

  • csutil.c: Client & Server Utility Function, containing functions required for communication between clients and servers, encapsulating many socket operations

  • stuutil.c: Student Utility Function, encapsulating functions for creating, querying, and writing to student databases

  • chatutil.c: Chatting Utility Function, implementing a custom chat application layer protocol based on UDP

It also includes the header files for these three function libraries: csutil.h, stuutil.h, and chatutil.h.

To facilitate compiling and linking the numerous code files together, a Makefile was also written. The 6 programs of this experiment are compiled and output as server_reg, client_reg, server_check, client_check, server_chat, and client_chat respectively.

2.2 Socket Operation Encapsulation in csutil.c

Since this experiment focuses on socket programming, regular operations can be written into functions in csutil.c. This way, when duplicate functions are needed later, simply adding #include "csutil.h" allows reuse of this code.

2.2.1 Exception Handling Function

Many functions when using sockets may generate errors. According to UNIX programming principles, when an exception occurs in a function provided by UNIX, the exception code is stored in the extern global variable errno, which requires including the errno.h header file. To obtain the string information corresponding to the error code, the perror function can be used, which requires including the string.h header file.

Here, due to the simplicity of the program logic, once an error is encountered, the function name where the error occurred and the error message are printed, and the program exits with a return value of 1:


// Exception handling. If an exception occurs, print error information and exit directly
// funcname: Name of the function where the error occurred
void throw_exception(char *funcname)
{
    perror(funcname);
    exit(1);
}

2.2.2 Conversion Functions between IP:port and sockaddr Structure

When using UNIX’s bind, connect, and sendto functions, a sockaddr structure needs to be passed in. However, we usually manually enter an IP address and port number. Therefore, the get_addr function was written to convert an IP+port number to a sockaddr_in structure:


// Convert IPv4:Port to `struct sockaddr_in`
// ip:   IP address to be converted
// port: Port number to be converted
// addr: Pointer to the structure for storing results
void get_addr(char *ip, int port, struct sockaddr_in *addr)
{
    int ip_net, port_net;

    if (inet_pton(AF_INET, ip, &ip_net) < 0)
        throw_exception("inet_pton");
    port_net = htons(port);

    addr->sin_family = AF_INET;
    addr->sin_addr.s_addr = ip_net;
    addr->sin_port = port_net;
    bzero(addr->sin_zero, sizeof(addr->sin_zero));
}

Meanwhile, when using UNIX’s accept and recvfrom functions, we need to know which client (from which IP and port) sent data to us. Therefore, it is also necessary to convert the sockaddr structure obtained from these two functions to an IP+port number:


// Convert `struct sockaddr_in` to IPv4:Port
// ip:   Pointer to store IP address
// port: Pointer to store port number
// addr: Pointer to the structure to be converted
void get_ip_port(char *ip, int *port, struct sockaddr_in *addr)
{
    if (inet_ntop(AF_INET, &addr->sin_addr.s_addr, ip, INET_ADDRSTRLEN) == NULL)
        throw_exception("inet_ntop");
    *port = ntohs(addr->sin_port);
}

The conversion functions here do not use inet_addr, inet_ntoa, and other functions provided in the experiment PPT, because Chapter 16 of the book “Advanced Programming in the UNIX Environment” mentions that these two functions have poorer compatibility compared to inet_pton and inet_ntop used here.

These conversions only consider IPv4 address strings and do not support inputting domain names for IP address resolution. For IPv6 addresses, replace the sockaddr_in structure with sockaddr_in6 and AF_INET with AF_INET6.

2.2.2 Server Creation Function

2.2.2.1 Function Prototype

Since the code for creating a server is frequently reused in this experiment, the process of creating a server is encapsulated into the make_server function. The function prototype is as follows:


// Create a server running on `sip`:`sport`
// sip:      Server IP
// sport:    Server port
// protocol: Application layer protocol, can be TCP or UDP
// handler:  Function to handle data sending and receiving
// block:    Whether to block connections (whether it is iterative mode)
void make_server(char *sip, int sport, int protocol, void (*handler)(int), bool block);
  • sip and sport: This function runs the server on sip:sport. In variable names appearing below, those prefixed with s indicate server-side information, and those prefixed with c indicate client-side information.

  • protocol: The transport layer protocol of the server is specified by protocol, which can be TCP or UDP. These macro constants are defined in csutil.h as SOCK_STREAM and SOCK_DGRAM respectively.

  • handler: The processing function when the server encounters a user connection. This function needs to accept one parameter, which is the (socket) file descriptor fd used for the connection, i.e., the return value of the accept function (for TCP protocol) or the return value of the socket function (for UDP protocol).

  • block: Whether the server blocks during the process of handling user connections. That is, whether the server can handle concurrent requests simultaneously with multiple processes.

The block parameter only applies to TCP. For UDP chat rooms, since UDP is connectionless, there will be no blocking when connecting with clients, so this parameter can be set arbitrarily.

The specific implementation of this function is as follows. First, perform the fixed socket and bind operations:


char cip[INET_ADDRSTRLEN];
int cport, sfd, cfd, caddr_len;
struct sockaddr_in saddr, caddr;
pid_t pid;

if ((sfd = socket(AF_INET, protocol, 0)) < 0)
    throw_exception("socket");
get_addr(sip, sport, &saddr);
if (bind(sfd, (struct sockaddr *)&saddr, sizeof(saddr)) < 0)
    throw_exception("bind");
printf("Serving at %s:%d\n", sip, sport);

Here, the length of the cip array used to store the client IP address is set to the maximum length of an IPv4 address INET_ADDRSTRLEN, which is 16 (including the final \0). This constant is defined in the arpa/inet.h header file.

2.2.2.2 Iterative TCP Server

Next, perform different operations according to different options. First is the iterative TCP server:


// Iterative TCP
if (protocol == TCP && block)
{
    if (listen(sfd, SOMAXCONN) < 0)
        throw_exception("listen");
    while (1)
    {
        if ((cfd = accept(sfd, (struct sockaddr *)&caddr, &caddr_len)) < 0)
            throw_exception("accept");
        get_ip_port(ip, &port, &caddr);
        printf("\nAccepted connection from %s:%d\n", ip, port);
        handler(cfd);
        close(cfd);
    }
}

It is necessary to first call listen, then simply handle one accept in a loop. After accept, the client’s address information is printed in a user-friendly manner, and then the handler function is called to implement custom socket read/write operations to communicate with the client.

The second parameter of listen here is not set to 0 as in the experiment PPT. According to UNIX documentation, the maximum number of TCP connections is defined by the macro SOMAXCONN in the sys/socket.h header file. On the local Linux system, this value is 128.

2.2.2.3 Concurrent TCP Server

Second is the concurrent TCP server:


// Concurrent TCP
else if (protocol == TCP && !block)
{
    if (listen(sfd, SOMAXCONN) < 0)
        throw_exception("listen");
    signal(SIGCHLD, SIG_IGN); // Avoid zombie processes
    while (1)
    {
        if ((cfd = accept(sfd, (struct sockaddr *)&caddr, &caddr_len)) < 0)
            throw_exception("accept");
        get_ip_port(ip, &port, &caddr);
        printf("\nAccepted connection from %s:%d\n", ip, port);
        if ((pid = fork()) < 0)
            throw_exception("fork");
        else if (pid == 0)
        {
            close(sfd);
            handler(cfd);
            close(cfd);
            exit(0);
        }
        close(cfd);
    }
}

Unlike the iterative TCP server, immediately after accept, the program calls fork to create a child process, which handles the newly established connection, while the parent process continues to loop and wait for other connections.

Note some details. In UNIX systems, if the parent process does not call a series of functions in sys.wait.h to obtain the child process’s termination information before the child process ends, the child process becomes a zombie process, occupying system resources. However, we cannot use wait in the while loop of the parent process here, because once wait is used in the loop, our parent process is also blocked, becoming an iterative TCP server. Therefore, there are two ways to avoid this:

  • One is to fork twice, handle the request in the child process of the child process, end the child process in advance, and use wait to obtain the child process information. This way, the child process of the child process will be taken over by the init process, avoiding the generation of zombie processes.

  • The other is through a signal handling function. When a child process terminates, the parent process receives the SIGCHLD signal. Therefore, if we perform wait when receiving this signal, or directly ignore the signal and let the child process information be deleted directly from the system’s process table entry, zombie processes can be avoided. Here, the same approach as in the experiment PPT is used to ignore the signal: call signal(SIGCHLD, SIG_IGN) in the parent process, which requires including the signal.h header file.

At the same time, to further save resources, when a process calls fork, its file table entry is also copied, just like calling the dup function. However, in the child process here, we do not need the parent process’s sfd (socket file descriptor), and in the parent process, we do not need the child process’s cfd (file descriptor for a TCP connection). Therefore, they can be closed in their respective branches.

2.2.2.4 UDP Server

Since UDP is connectionless, its implementation is the simplest, requiring neither listen nor accept, and directly calling handler in a loop for data sending and receiving:


// UDP
else if (protocol == UDP)
{
    while (1)
        handler(sfd);
}

2.2.3 Function for Clients to Initiate Communication with Servers

The client initiating communication with the server is also a frequently reused function in this experiment. Similar to creating a server, this function needs to provide parameters such as server IP, server port number, transport layer protocol, and handler function. Then the function establishes a connection with the server through connect, obtains the file descriptor cfd used for the connection, then calls handler, passing in the cfd parameter for data sending and receiving processing. The specific implementation is as follows:


// Initiate a connection to the server at `sip`:`sport`
// sip:      Server IP
// sport:    Server port
// protocol: Application layer protocol, can be TCP or UDP
// handler:  Function to handle data sending and receiving
void contact_server(char *sip, int sport, int protocol, void (*handler)(int))
{
    int fd;
    struct sockaddr_in saddr;

    if ((fd = socket(AF_INET, protocol, 0)) < 0)
        throw_exception("socket");
    get_addr(sip, sport, &saddr);

    if (connect(fd, (struct sockaddr *)&saddr, sizeof(saddr)) < 0)
        // UDP works too
        throw_exception("connect");
    handler(fd);
    close(fd);
}

Note that connect here does not exclude the UDP protocol! This is because according to Section 16.4 of Chapter 16 in “Advanced Programming in the UNIX Environment”, the connect function can also be called under the UDP protocol. The effect of doing this is that each time send is used to send data, it will be sent to the address specified by the connect function by default. Moreover, when calling the recv function, it will only receive datagrams from the address specified by the connect function. There is no need for us to specify the address each time using the sendto and recvfrom functions.

2.2.4 Data Sending and Receiving Functions

Since the sending and receiving functions provided by UNIX are still relatively low-level and lack exception handling, more user-friendly encapsulations have been written for the recv, send, recvfrom, and sendto functions: recv_data, send_data, recv_data_from, send_data_to, with exception handling added. Taking recv_data_from as an example:


// Encapsulated and more convenient `recvfrom`
// fd:   socket file descriptor
// buf:  buffer
// ip:   pointer to store the sender's IP
// port: pointer to store the sender's port
void recv_data_from(int fd, void *buf, char *ip, int *port)
{
    struct sockaddr_in caddr;
    int caddr_len = sizeof(caddr);

    bzero(buf, MAXLINE);
again:
    if (recvfrom(fd, buf, MAXLINE, 0, (struct sockaddr *)&caddr, &caddr_len) < 0)
    {
        if (errno == EINTR)
            goto again; // Prevent interrupted system call
        throw_exception("recvfrom");
    }
    get_ip_port(ip, port, &caddr);
}

It can be seen that it does not need to pass in a sockaddr structure, but directly passes in the addresses to receive the IP and port number. And there is no need to specify the buffer length, because in csutil.h, the length of the buffer for both the sender and receiver is uniformly set to MAXLINE, which is 8192. There is also no need to manually initialize the buffer to 0 before each reception, because the function has already done zero-filling for us through bzero.

Note that goto is used in this function to prevent recv series functions from being interrupted during execution. Because signal handling functions are used in subsequent programs, when the program receives a signal, for ordinary system calls, they will be executed to completion before the signal handling function is executed, but for slow system calls, such as reading and writing on network sockets, when a signal is received, the system call will be interrupted and return the error code EINTR. Therefore, if an error is reported and exited directly at this time, the required functions of the program cannot be achieved. The solution is to re-execute the current system call once this error is encountered, and the simplest solution is to use goto.

This is also the sample approach in Chapter 10 of “Advanced Programming in the UNIX Environment”.

Similarly, taking the send_data function as an example:


// Encapsulated and more convenient `send`
// fd:  socket file descriptor
// buf: buffer
// len: length of the data part to be sent, automatically calculated if 0
void send_data(int fd, void *buf, int len)
{
    if (len == 0)
        len = strlen(buf) + 1;
    if (send(fd, buf, len, 0) < 0)
        throw_exception("send");
}

It can be seen that it provides an option: if the length is set to 0, there is no need for us to manually calculate the length of the data to be sent. This is very user-friendly when sending string constants.

2.3 Simple Network Registration Service (Iterative)

2.3.1 stuutil.c Student Database

To implement student information storage, addition, and query, stuutil.c was written. Here, student information is stored in the form of a text file: the first line is the student’s name, and the second line is the student’s ID number. Each student’s information is also separated by a line. The location of the text file is DB_PATH defined in stuutil.h, which is student.txt in the running directory of the server program.

The add_student function is needed here to add student information to the database, that is, to append information directly to the text file:


// Add a student to the database
// name:   student name
// number: student ID
void add_student(char *name, char *number)
{
    FILE *fp;

    fp = fopen(DB_PATH, "at");
    fputs(name, fp);
    fputs("\n", fp);
    fputs(number, fp);
    fputs("\n", fp);
    fclose(fp);
}

2.3.2 Server Program

With stuutil.h and the previous csutil.h, the subsequent coding process is extremely easy. Because we only need to obtain the IP and port from the command line, specify the protocol, and write our own handler function. For example, the complete code of the server is as follows:


/* Registration Server */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "csutil.h"
#include "stuutil.h"

// Handle data sending and receiving with the client
// cfd: socket file descriptor
void handler(int cfd)
{
    char buf_name[MAXLINE], buf_number[MAXLINE];
    while (1)
    {
        // First round of sending and receiving
        send_data(cfd, "Please input student name:", 0);
        recv_data(cfd, buf_name);
        if (strcmp(buf_name, "bye") == 0)
            return;
        printf("Student name:\t%s\n", buf_name);
        // Second round of sending and receiving
        send_data(cfd, "Please input student number:", 0);
        recv_data(cfd, buf_number);
        printf("Student number:\t%s\n", buf_number);
        add_student(buf_name, buf_number);
    }
}

int main(int argc, char *argv[])
{
    char *ip;
    int port;

    if (argc != 3)
    {
        printf("Usage: ./server_reg server_ip server_port\n");
        return 0;
    }
    ip = argv[1];
    port = atoi(argv[2]);
    make_server(ip, port, TCP, handler, true); // Create an iterative TCP server
    return 0;
}

Among them, in the make_server function, the protocol is set to TCP and the iterative mode is set. In the handler function that processes client requests, two rounds of sending and receiving are performed in each loop, corresponding to the name and student ID respectively. Exit when the entered name is bye. At the same time, the received name and student ID are printed on the server’s screen, and then stored through the add_student function mentioned in 2.3.1.

2.3.3 Client Program

For the client, its structure is roughly similar. Only need to replace make_server in the main function with contact_server mentioned in 2.2.3, and implement the handler to handle the connection with the server. The implementation of the client’s handler function is as follows:


// Handle data sending and receiving with the server
// cfd: socket file descriptor
void handler(int cfd)
{
    char buf[MAXLINE];
    while (1)
    {
        // First round of sending and receiving, input name
        recv_data(cfd, buf);
        printf("[Server] %s\n[Client] ", buf);
        fgets(buf, MAXLINE, stdin);
        buf[strlen(buf) - 1] = '\0';
        send_data(cfd, buf, 0);
        if (strcmp(buf, "bye") == 0)
            break;
        // Second round of sending and receiving, input student ID
        recv_data(cfd, buf);
        printf("[Server] %s\n[Client] ", buf);
        fgets(buf, MAXLINE, stdin);
        buf[strlen(buf) - 1] = '\0';
        send_data(cfd, buf, 0);
    }
}

Two rounds of sending and receiving are also performed. Exit if the input is bye.

2.4 Simple Network Check-in Service (Concurrent)

2.4.1 stuutil.c Student Database

Same as Section 2.3.1, implement a simple student query function has_student in stuutil.c:


// Check if the student exists in the database
// name:   student name
// return: true if exists, false if not
bool has_student(char *name)
{
    char buf[MAXLINE];
    int len;
    FILE *fp;

    fp = fopen(DB_PATH, "rt");
    while (!feof(fp))
    {
        fgets(buf, MAXLINE, fp);
        buf[strlen(buf) - 1] = '\0';
        if (strcmp(buf, name) == 0)
        {
            fclose(fp);
            return true;
        }
        fgets(buf, MAXLINE, fp);
        buf[strlen(buf) - 1] = '\0';
    }
    fclose(fp);
    return false;
}

Since check-in only considers the name, if the student’s name exists, return true.

2.4.2 Server Program

The main function of the server program only needs to change the last parameter in make_server from true in 2.3.2 to false, that is, create a concurrent TCP server:


make_server(ip, port, TCP, handler, false);

The handler function of the server program is as follows:


// Handle data sending and receiving with the client
// cfd: socket file descriptor
void handler(int cfd)
{
    char buf[MAXLINE];
    send_data(cfd, "Please input student name:", 0);
    while (1)
    {
        recv_data(cfd, buf);
        if (strcmp(buf, "bye") == 0) // Case of "bye"
            return;
        if (has_student(buf)) // Case of finding the student (exists)
        {
            send_data(cfd, "Successfully checked in! Next name:", 0);
            printf("%s checked in\n", buf);
        }
        else // Case of finding the student (does not exist)
            send_data(cfd, "No such student! Next name:", 0);
    }
}

Only one round of sending and receiving is performed per loop. If the received student name exists, it is also output on the server’s screen (convenient for teachers to view check-in status in the background). If it does not exist, an error message is also sent to the client. If bye is received, exit.

2.4.3 Client Program

The client’s handler is simpler, performing one round of sending and receiving per loop, and exiting if bye is input:


// Handle data sending and receiving with the server
// cfd: socket file descriptor
void handler(int cfd)
{
    char buf[MAXLINE];
    while (1)
    {
        // Receive server prompt
        recv_data(cfd, buf);
        printf("[Server] %s\n[Client] ", buf);
        // Send name to server
        fgets(buf, MAXLINE, stdin);
        buf[strlen(buf) - 1] = '\0';
        send_data(cfd, buf, 0);
        if (strcmp(buf, "bye") == 0) // Exit loop if "bye" is input
            break;
    }
}

2.5 Chat Room Based on UDP Socket

2.5.1 chatutil.c Application Layer Protocol

2.5.1.1 Protocol Packet Format

To implement chat room user registration, message transmission from clients to servers, and server broadcasting of messages based on the UDP protocol, the packet format of a self-designed application layer protocol (hereinafter referred to as the Simple Chat Protocol) is defined in chatutil.h. The header part structure is struct header:


#pragma once
#pragma pack(push, 1)
struct header // Application layer packet header
{
    uint16_t id;
    uint8_t flags;
    uint8_t len;
};
#pragma pack(pop)

#pragma pack(1) is used to enforce 1-byte alignment. It corresponds to the following:

0-7bit8-15bit
Client ID (high 8 bits)Client ID (low 8 bits)
Flag bitsData part length

The data part immediately follows the header.

  • Client ID: Each client connected to the UDP chat room server has a unique client ID to identify the client’s identity. If a client has just entered the chat room but not yet registered, the default ID (value 0) is used. Since the ID is 16 bits, this UDP server can support a maximum of 65535 clients chatting simultaneously.

  • Flag bits: Used to mark the function of the packet. The specific distribution of the 8 bits from high to low is:

0x800x400x200x100x080x040x020x01
ReservedReservedBRDFINSNDREGNAKACK
  • ACK: Confirmation packet from the server agreeing to registration, and confirmation packet responding to messages sent by the client

  • NAK: Negative packet from the server refusing registration (duplicate name, maximum number of online clients reached)

  • REG: Registration request packet from the client

  • SND: Chat message packet sent by the client to the server

  • FIN: Packet from the client exiting the chat room

  • BRD: Packet from the server broadcasting chat messages and other notification information to clients

  • Data part length: Length of the data part in bytes.

2.5.1.2 Protocol Confirmation Mechanism

To ensure that the client can detect in a timely manner if the server has crashed (instead of continuously receiving nothing and not knowing whether no one is sending messages or the server has crashed), a confirmation mechanism is introduced for communication from the client to the server:

  • The server will send a REG ACK or REG NAK confirmation in response to the REG packet sent by the client

  • The server will send an SND ACK confirmation in response to the SND packet sent by the client

  • The server will send a FIN ACK confirmation in response to the FIN packet sent by the client

  • If the client does not receive confirmation within a certain period of time, it is considered that the server has crashed. The timeout period is defined as TIMEOUT in chatutil.h, which is 8 seconds.

2.5.1.2 Application Layer Sending and Receiving Functions

To handle the self-designed application layer protocol, sending and receiving functions for this protocol also need to be designed. The sending and receiving of the server and client are different. Due to the connectionless nature of UDP, the server needs to know the address of the other party, i.e., sendto and recvfrom must be used. Taking the server’s sending function as an example:


// Server sends Simple Chat Protocol packets
// cfd:   socket file descriptor
// cip:   recipient client IP address
// cport: recipient client port number
// id:    recipient client ID
// flags: protocol packet flag bits
// data:  protocol packet data part
void server_send_chat(int cfd, char *cip, int cport, int id, int flags, char *data)
{
    int len = strlen(data) + 1;
    struct header hd;
    char buf[sizeof(hd) + len];

    hd.id = id;
    hd.flags = flags;
    hd.len = len;
    memcpy(buf, &hd, sizeof(hd));
    memcpy(buf + sizeof(hd), data, len);
    send_data_to(cfd, buf, cip, cport, sizeof(hd) + len);
}

It is necessary to know the ID, IP address, and port number of the client to send to, as well as the flags to specify. Then the function first constructs the header of the Simple Chat Protocol using this information, then splices the header and data part together, copies them into buf, and then sends them using the send_data_to function in csutil.c.

Taking the client’s receiving function as another example. Since in 2.2.3, the client has specified the server’s address in advance through the connect function, the client does not need to specify the server’s address again in the sending and receiving functions:


// Client receives Simple Chat Protocol packets
// cfd:     socket file descriptor
// flags:   pointer to protocol packet flag bits
// data:    pointer to protocol packet data part
// timeout: time to wait for ACK (seconds), server is considered down if exceeded
// return:  sender client ID
int client_recv_chat(int cfd, int *flags, char *data, int timeout)
{
    struct timeval tv;
    struct header hd;
    char buf[MAXLINE];

    tv.tv_usec = 0;
    tv.tv_sec = timeout;
    if (setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) // Set receive wait time
        throw_exception("setsockopt");
    recv_data(cfd, buf);
    tv.tv_sec = 0;
    if (setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) // Restore receive wait time
        throw_exception("setsockopt");
    memcpy(&hd, buf, sizeof(hd));
    memcpy(data, buf + sizeof(hd), hd.len);
    *flags = (int)hd.flags;

    return hd.id;
}

Similarly, after the client receives the data, it first stores it in buf, then copies it to the header structure and data data part in sequence. Then assign the flags field in header to the variable pointed to by the flags pointer, and return the client ID in header.

To account for the situation where the server suddenly goes offline, this function accepts a timeout parameter (in seconds). The timeout time for recv series functions is set through the setsockopt function. If a timeout occurs, recv will generate an error message of “Resource not available” and exit the program.

2.5.2 Server Program

2.5.2.1 Main Function Part

Similar to the server programs in 2.3.2 and 2.4.2, only need to change the protocol parameter of make_server to UDP in the main function:


make_server(ip, port, UDP, handler, false);
2.5.2.2 Client Information Storage

Define a client structure to store individual user information, including IP, port number, name, and whether alive:


// Client information structure
struct client
{
    bool alive;               // Whether alive
    char ip[INET_ADDRSTRLEN]; // Client IP
    char name[MAXNAME];       // Client name
    int port;                 // Client port number
};

The mechanism for introducing the alive field here is: during initialization, all alive values are false. When a client connects, the alive corresponding to the client ID is set to true. When the client sends a FIN to the server, the server sets the alive corresponding to the client ID to false. When a new user joins, if it is found that the content corresponding to an ID is false, the ID can be assigned to the new user, directly overwriting the original data. This eliminates the need for adding and deleting array elements.

Then, create a clis array where the array subscript is the client ID. This allows obtaining client information with a single lookup by ID, without writing a loop for searching:


struct client clis[MAXCLIENT];

To check for duplicate names during user registration, the has_name function is written:


// Check if the name exists among currently connected clients
// name:   name to find
// return: true if exists, false if not
bool has_name(char *name)
{
    for (int i = 1; i < MAXCLIENT; i++)
        if (clis[i].alive && strcmp(clis[i].name, name) == 0)
            return true;
    return false;
}

After agreeing to user registration, an ID needs to be assigned to the user, and the user information needs to be stored in the structure corresponding to the ID. Therefore, the cli_alloc function is written:


// Allocate an ID for a newly joined client
// name:   client name
// ip:     client IP address
// port:   client port number
// return: assigned client ID (0 means full)
int cli_alloc(char *name, char *ip, int port)
{
    for (int i = 1; i < MAXCLIENT; i++)
        if (!clis[i].alive)
        {
            clis[i].alive = true;
            strcpy(clis[i].name, name);
            strcpy(clis[i].ip, ip);
            clis[i].port = port;
            return i;
        }
    return 0;
}

Detect the first client with the alive field set to false starting from subscript 1 and assign it to the new client. If the server is full, return 0.

2.5.2.3 Application Layer Packet Processing

In the application layer handler function, first call server_recv_chat in chatutil.c to receive an application layer packet of the Simple Chat Protocol sent by a client, and print the packet information:


char cip[INET_ADDRSTRLEN], data[MAXDATALEN], appended_data[MAXDATALEN]; // appended_data is the modified sending content by the server
    int cport, id, flags;

// Print information of the application layer packet received by the server
id = server_recv_chat(sfd, cip, &cport, &flags, data);
printf("%s:%d, id=%d, flags=%d, data=%s\n", cip, cport, id, flags, data);

Then, perform different processing according to the different flag bits of the packet. First is the REG flag bit, handling user registration:


// Case of receiving registration packet
if (flags & F_REG)
{
    if (has_name(data)) // Name conflict, send NAK packet feedback
        server_send_chat(sfd, cip, cport, id, F_REG | F_NAK, "Name has already used");
    else
    {
        id = cli_alloc(data, cip, cport);
        if (id != 0)
        {
            server_send_chat(sfd, cip, cport, id, F_REG | F_ACK, ""); // Registration successful, send ACK packet feedback
            sprintf(appended_data, "%s (%s:%d) joins the conversation", clis[id].name, clis[id].ip, clis[id].port);
            for (int i = 1; i < MAXCLIENT; i++) // Broadcast to other clients that this member has joined
                if (clis[i].alive)
                    server_send_chat(sfd, clis[i].ip, clis[i].port, i, F_BRD, appended_data);
        }
        else // Server full, send NAK packet feedback
            server_send_chat(sfd, cip, cport, id, F_REG | F_NAK, "Server is full");
    }
}

When user registration is successful, the server broadcasts the information that the client has joined the chat to all clients (including the registered client).

Next, consider the SND flag bit:


// Case of receiving send packet
else if (flags & F_SND)
{
    server_send_chat(sfd, cip, cport, id, F_SND | F_ACK, ""); // Send ACK packet feedback
    sprintf(appended_data, "[%s] %s", clis[id].name, data);
    for (int i = 1; i < MAXCLIENT; i++) // Broadcast the message sent by this client to other clients
        if (clis[i].alive && i != id)
            server_send_chat(sfd, clis[i].ip, clis[i].port, i, F_BRD, appended_data);
}

The server first replies with an ACK to the sender, then forwards the chat message received from the client to other clients.

Finally, consider the FIN flag bit:


// Case of receiving finish packet
else if (flags & F_FIN)
{
    server_send_chat(sfd, cip, cport, id, F_FIN | F_ACK, ""); // Send ACK packet feedback
    clis[id].alive = false;
    sprintf(appended_data, "%s (%s:%d) leaves the conversation", clis[id].name, clis[id].ip, clis[id].port);
    for (int i = 1; i < MAXCLIENT; i++) // Broadcast to other clients that this member has left
        if (clis[i].alive && i != id)
            server_send_chat(sfd, clis[i].ip, clis[i].port, i, F_BRD, appended_data);
}

The server also first replies with an ACK, then sets the client’s flag bit to false, and finally broadcasts the information that the member has left to other members.

2.5.3 Client Program

2.5.3.1 Main Function Part

Because ordinary program input and output can only be performed sequentially, just like the previous registration and check-in services, each performing one round of sending and receiving. However, chat room users should be able to send messages whenever they want. Therefore, the main function first registers the processing function switch_mode for the Ctrl+C signal, allowing the client to immediately switch to input mode after pressing Ctrl+C.

The part for connecting to the server only needs to slightly modify the parameters of contact_server to UDP:


signal(SIGINT, switch_mode); // Register the handler for the Ctrl+C signal. During chatting, Ctrl+C switches to input mode
...
contact_server(ip, port, UDP, handler);

The subsequent registration and message receiving process are implemented by handler, while the message sending process is implemented by signal handling.

2.5.3.2 Registration and Message Receiving Process

First is the registration process:


// Registration process
while (1)
{
    printf("Please input your name: ");
    fgets(name, MAXNAME, stdin);
    name[strlen(name) - 1] = '\0';
    client_send_chat(cfd, 0, F_REG, name);
    while (1) // Wait for F_ACK from the server, exit directly with error if timeout
    {
        id = client_recv_chat(cfd, &flags, data, TIMEOUT);
        if ((flags & F_REG) && (flags & (F_ACK | F_NAK)))
            break;
    }
    if (flags & F_ACK) // If the server agrees to registration, exit the loop; otherwise, choose a new name
        break;
    printf("%s\n", data);
}

It can be seen that a timeout mechanism is also introduced in the registration process. If no ACK or NAK reply is received within the timeout period, the server is considered down and the program exits.

The message receiving process is simple: just keep looping to receive and print:


// Message receiving process
while (1)
{
    client_recv_chat(cfd, &flags, data, 0);
    if (flags & F_BRD)
        printf("%s\n", data);
}

Note that the timeout period here is set to 0 (infinite), because there may be situations where everyone is idle and not speaking. Therefore, to detect if the server is functioning properly, it is necessary to rely on the confirmation in the subsequent message sending process.

2.5.3.4 Message Sending Process

The following is part of the code for switch_mode:


// Switch to message input mode (triggered by pressing Ctrl+C)
void switch_mode()
{
    ...
    // Print own name, prompt for input, get input
    printf("\r[%s] ", name);
    fgets(data, MAXDATALEN, stdin);
    data[strlen(data) - 1] = '\0';
 ...
    // Case of inputting other chat information
    client_send_chat(cfd, id, F_SND, data);
    while (1) // Wait for F_ACK from the server, exit directly with error if timeout
    {
        client_recv_chat(cfd, &flags, data, TIMEOUT);
        if ((flags & F_SND) && (flags & F_ACK))
            break;
        else if (flags & F_BRD)
            printf("%s\n", data);
    }
}

It can be seen that after pressing Ctrl+C, the screen first prints the user’s own name to prompt for input, then after pressing Enter, the client sends the input information to the server and starts a timeout to wait for the server’s ACK packet. If the packet is not received within the timeout period, the server is considered down and the program ends. If a BRD broadcast message is received before the ACK, the message is also printed normally.

3. Code Running and Exploration

Use the make command to perform automated compilation with the previously written Makefile, then enter ./program_name ip port to run the client or server.

3.1 Simple Network Registration Service (Iterative)

3.1.1 Normal Running Effect

Open 1 server (middle right), 4 clients (left side and top right), and a terminal to view the netstat command. The effect is as follows.

Step 1: Start the server, running on local port 6666. Then start the clients in the order of top left, middle left, bottom left, top right. Then the top left client communicates with the server first, and the others wait. From netstat, all connections are in the ESTABLISHED state, meaning the three-way handshake is completed. And from the information output by the server, the process with port number 36656 is currently communicating with the server. As shown in the figure below:

Step 2: Enter bye in the top left client, then the middle left client immediately starts communicating with the server. Meanwhile, from netstat, the original connection from 6666 to 36656 is closed, and the connection from port 36656 to port 6666 is in the TIME_WAIT state before closing. This is because, as shown in Figure 5-29 of the textbook, after the server receives the client’s FIN and FIN ACK, it closes the connection, while the client waits for 2MSL before closing the connection after receiving the server’s FIN ACK. And from the server output information, the current client port number is 36658. As shown in the figure below:

Step 3: Enter bye in the middle left client, and the bottom left client immediately connects. It can be seen that connections are processed in the order of connection initiation. At this time, in netstat, the connection from port 56658 to port 6666 is also in the TIME_WAIT state, while the poor top right client is still waiting. As shown in the figure below:

Step 4: Enter bye in the bottom left client, the top right client communicates with the server, then enter bye after inputting. After waiting for a period of time (to exceed 2MSL), check with netstat again, and all the above connections are released. As shown in the figure below:

It can be seen that its performance meets expectations.

As mentioned above, the backlog of TCP connections here is set to SOMAXCONN, which is 128 on the local system, so the connections of these clients are all accepted and kept waiting.

3.1.2 TCP Connection Limit Scenario

Previously, in the make_server function of csutil.c, the number of TCP connections (i.e., the backlog parameter of listen) was set to SOMAXCONN, which is 128, neither 0 nor 1. If it is changed to 0 and the above steps are repeated, as shown in the figure below:

It can be found that when using netstat in the bottom right corner, the connections from the clients with the highest port numbers (36690 for bottom left, 36692 for top right) to 6666 are in the SYN_SENT state, completing only one handshake. And soon after, these two clients show a “Connection timed out” error, indicating that they did not successfully establish a connection with the server. This is because, as shown in Figure 5-28 of the textbook, a three-way handshake is required before a TCP connection is established. Due to the limit on the maximum number of TCP connections, after the last two clients send a SYN, the server does not respond with a SYN ACK, resulting in only one handshake being completed and the connection ending due to handshake timeout.

Now change it to 1 and repeat the above steps, as shown in the figure below:

It can be seen that the connection capacity for one more client is added this time. Based on this, it is speculated that the maximum number of connectable clients may be backlog + 2.

3.1.3 Client Binding to Fixed Port Scenario

To do this, insert the following code into the contact_server function of csutil.c to bind the client to local port 5555:


struct sockaddr_in caddr;
get_addr("127.0.0.1", 5555, &caddr);
if (bind(fd, (struct sockaddr *)&caddr, sizeof(caddr)) < 0)
    throw_exception("bind");

Then, recompile with make, first let the client communicate with the server, then close it, and then communicate with the server again. The first communication is normal, but if communication is attempted immediately after ending, the client program will prompt “Address already in use”, indicating that the previously bound port number 5555 is already occupied, as shown in the figure below:

It can be seen that the previously established connection is still in the TIME_WAIT state. Therefore, if binding to the same port number again before the connection is completely released, binding will fail because the previous port number is still in use. However, if a connection is initiated after waiting for a period of time (after 2MSL), communication can be resumed:

Therefore, it is not recommended for clients to bind to the same port number because after the client closes the connection, the connection needs to wait for a period of time to be completely released, so the port number cannot be reused immediately, making it impossible to establish a connection for a period of time.

3.1.4 Byte Order Conversion of IP Address and Port

Since the inet_ntop and inet_pton functions have built-in network byte order conversion. Therefore, only the scenario where the network byte order of the port is not converted can be demonstrated. Change in the get_addr function in csutil.c:


port_net = htons(port);

to

```Plain Text

port_net = port; ```

After compiling with make, still run the server on port 6666:

It can be seen that communication is still possible normally, because both the client and server use the get_addr function, so the port named 6666 is actually treated as port 2586. However, in practical applications, such errors are not allowed.

Next, analyze the reason. Convert 6666 to a 16-bit binary number, divided into two bytes:

6666_{10}=0001\;1010\quad0000\;1010_2

Reversing the order of these two bytes (while keeping the order within the bytes unchanged) and converting back to decimal gives 2586:

0000\;1010\quad0001\,1010_2=2586_{10}

It can be seen that different endianness is used to store data on the network and on the local machine, so conversion is necessary.

3.2 Simple Network Check-in Service (Concurrent)

3.2.1 Normal Running Effect

Open 1 server (bottom right), also running on port 6666; 5 concurrent clients (left side, top right, middle right). The effect is as follows:

Figure 1: All five clients can establish connections with the server simultaneously, and the server also displays information about 5 clients connecting.

Figure 2: The 5 clients can send names to the server for check-in simultaneously, and the server prints check-in information. For non-existent names, error prompts are also provided:

It can be seen that its performance meets expectations.

3.2.2 Impact of Closing cfd in Parent Process

In the above code, the descriptor obtained by the server through socket is sfd, and the descriptor for a single connection obtained through accept is cfd. The above code closes cfd in the parent process, and some explanations have been mentioned in 2.2.2.3.

The following shows the scenario where the parent process closes cfd on port 6666 (left side) and the scenario where the parent process does not close cfd on port 8888 (right side). There is no difference in communication, but when the client exits, the netstat results on both sides are different:

The left side normally enters the TIME_WAIT state, and the connection is completely released soon after. The server on the right side is in the CLOSE_WAIT state, and the client is in the FIN_WAIT2 state, indicating that only two handshakes (FIN and ACK) have been completed. In other words, the server’s application process did not close the connection and did not actively initiate a FIN ACK.

This can be explained by the replication of UNIX file descriptors. When a process is forked, the file descriptors in the child process are like being processed by the dup function, meaning both the parent and child process file descriptors point to the same file table, which contains file status, current offset, and other file information. The file is truly closed only when the number of file descriptors pointing to a file table is 0; otherwise, it is only “closed” for a certain process and removed from the process table of that process. Therefore, if close is only called in the child process, although it is removed from the process table of the child process, it is not removed from the parent process, so the file table for the connection still exists, meaning the connection is not closed. Therefore, it is necessary to close cfd in the parent process; otherwise, system resources are wasted and the client cannot completely release the connection.

3.3 Chat Room Based on UDP Socket

3.3.1 Running Effect

Also start a server on local port 6666 (bottom right), then five clients chat. By default, it receives messages; to send messages, press Ctrl+C:

It can be seen that when registering a name in the bottom left client, an attempt to use a duplicate name results in registration failure. And the server sends prompts about users joining and exiting, along with their addresses. The server also records logs, retaining each application layer packet of the Simple Chat Protocol sent by clients.

It can be seen that during registration (inputting a name), the id of the packet received by the server is 0, and flags is 4 (representing REG). When sending messages, the server receives the respective user id, and flags is 8 (representing SND). When a user exits, flag is 16 (representing FIN).

3.3.2 Exception Capture

Following the above chat. If the server suddenly crashes (ends by pressing Ctrl+C), the client will report an error when sending messages to it. Here, because the port is already closed (rather than the server not responding), a “Connection refused” error occurs immediately:

If a public network address is entered randomly, the packet may never receive a response, and the previously introduced timeout mechanism takes effect. An error will be reported and the program will exit after an 8-second timeout, with the error content being “Resources unavailable”:

3.3.3 Real Environment: Unreliable Transmission of UDP

Next, deploy the server on port 6666 of the own server f5soft.site (public network address: 39.97.114.106:6666), and start 4 clients on the own computer to connect to the public network server. It can be found that most functions operate normally. However, due to the unreliable transmission of UDP and the unreliability of the underlying layer in the public network environment (unlike the reliable underlying layer of the local network), UDP datagram loss occurred in the public network environment!

It can be seen that UDP is indeed unreliable. To completely solve this problem, reliable transmission needs to be implemented in our application layer (Simple Chat Protocol), so in addition to the previous timeout mechanism, a retransmission mechanism and other measures need to be introduced.

4. Experiment Summary

In this experiment, we experienced and mastered:

  • Reducing program code volume through reasonable code reuse

  • Basics of UNIX network socket programming

  • UNIX multi-process parallel programming

  • Acquisition and conversion of socket network addresses

  • Creation of TCP and UDP servers

  • Communication between TCP/UDP clients and servers

  • Three-way handshake process for TCP connection establishment and four-way handshake process for connection release

  • Using the netstat command to view local connections and port status

  • Implementing a custom application layer protocol - Simple Chat Protocol

  • Differences between production and local environments and verification of UDP’s unreliable transmission

  • Code files are located in the src folder, including:

    • server_reg.c and client_reg.c: Simple network registration server and client

    • server_check.c and client_check.c: Simple network check-in server and client

    • server_chat.c and client_chat.c: UDP chat room server and client

    • csutil.c and csutil.h: Client & Server Utility Function, containing functions required for communication between clients and servers, encapsulating many socket operations

    • stuutil.c and stuutil.h: Student Utility Function, encapsulating functions for creating, querying, and writing to student databases

    • chatutil.c and chatutil.h: Chatting Utility Function, implementing a custom chat application layer protocol based on UDP

    • Makefile: Makefile for automated compilation

To compile and run, in a Linux environment, enter make in the src directory to generate compiled files

Download experiment code here