Cozis

Building web apps from scratch - The Listener Socket - Part 5

Now that we have a pretty good handle on how the HTTP protocol works, we can start with the implementation. The way we will go about this is to first set up a plain TCP echo server using system calls and document step by step what everything does (an echo server is one that repeats back any message it receives). Then, we will use this generic TCP server to actually process HTTP requests and serve responses. In this post we will talk about setting up a listening socket.

The code snippets used here are Linux-specific. The general points also apply to Windows with some minor fixes I mentioned in the "Windows" paragraph. At the end you will find the program in its entirety in a form that's compatible with both Linux and Windows.

Creating a Socket

First, we need to create a "socket" object to tell the system we are interested in accepting TCP connections on a given interface and port. This is done using the socket() function:

1int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
2if (listen_fd == -1) {
3 return -1;
4}
5

the first argument AF_INET tells the OS we want our TCP socket to be based on IP version 4. If we wanted to use IP version 6 or bluetooth, we would use a different value here.

The second argument SOCK_STREAM tells the OS we are going to use the socket interface for stream-oriented protocols.

The third argument specifies which stream-oriented protocol we are going to use specifically, or more generally which protocol we are going to use with the second argument's socket type. The value 0 is associated to the default protocol for that socket type, which is TCP in the case of SOCK_STREAM. At first I was confused about this and thought the third argument was redundant, but after talking to Anshuman on the blog's discord I realized that wasn't the case!

The return value is an integer that identifies the socket object which is stored in the operating system's kernel, or -1 if the function failed.

Enabling Address Reuse (optional)

This is an optional step helpful while developing servers. When a TCP connection that uses a given local address and port pair is terminated, the actively closing end enters the TIME-WAIT state (see RFC 9293) where no new connections can be established on the interface/port pair to let any residual network traffic related to it settle. This is extremely annoying when developing a server because it means you'll have to wait a couple minutes between runs. If you run the server before then, you'll get an "Address already in use" error. To avoid this, you can set the address reuse option on the socket:

1int one = 1;
2setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, (char*) &one, sizeof(one));
3

This will set the SO_REUSEADDR setting to 1 for this socket.

Binding to the Local Interface

We created a socket, but it's not doing anything yet since we need to configure it first. We can start by telling the OS which interface and port pair we want to listen on. We do so using the bind() function.

1// We want to listen for connections on this interface and port
2char addr[] = "127.0.0.1";
3int port = 8080;
4
5struct sockaddr_in bind_buf;
6// TODO: Write the address and port into "bind_buf"
7
8// Bind the socket to the specified interface
9if (bind(listen_fd, (struct sockaddr*) &bind_buf, sizeof(bind_buf)) < 0) {
10
11 // If an error occurred, close the socket and return
12 close(listen_fd);
13 return -1;
14}
15

This works by filling out the struct sockaddr_in structure with our parameters and then pass that to bind(). If we were using a different L3 protocol such as IPv6 we would be using a different address structure here.

The fields of struct sockaddr_in we care about initializing are sin_family, sin_port, sin_addr. The sin_family refers to the underlying level 3 protocol we are using, which is still AF_INET for IPv4, while the sin_port and sin_addr fields contains the port and address we will listen on.

1struct sockaddr_in bind_buf;
2
3// L3 protocol
4bind_buf.sin_family = AF_INET;
5
6// port
7bind_buf.sin_port = htons(port);
8
9// address
10if (inet_pton(AF_INET, addr, &bind_buf.sin_addr) != 1) {
11 close(listen_fd);
12 return -1;
13}
14

The way to set the sin_port field is by first converting the port value from host byte order to network byte order using the htons function. Without going too much into detail, different CPUs store multi-byte data types in different ways. This is referred to as the CPU's "endianess". Due to this difference, if CPUs wrote data to the network as they store it in memory, CPUs with different endianess would receive the proper bytes but translate them logically as a different value. To avoid this issue, whenever we write multi-byte values to the network, we must convert the byte ordering to the network's conventional byte order. The htons function does exactly this for 16 bit values.

Setting the sin_addr field is a bit trickier as we usually specify the IPv4 addresses in dot-decimal form. That's why we rely on the standard inet_pton function to convert the string to the raw 4 byte address. The resulting value already has the correct byte ordering here. The first argument inet_pton specifies which address format it should parse. For dot-decimal we pass it AF_INET. The second argument is the actual null-terminated string. The last argument is the memory location where the address's 32 bits would be written to. The return value is 1 on success and 0 or -1 on failure.

Listening for Connections

Now we can "activate" our socket by calling the listen() function. This will tell the OS to start performing the three-way handshakes and store the established connections in a backlog. After that we will be able to get connections from that backlog to our application by calling the accept() function:

1if (listen(listen_fd, 32) < 0) {
2 close(listen_fd);
3 return -1;
4}
5

the first argument is the listening socket, while the second one is the backlog size. If a client tries to establish a TCP connection while the backlog is full, it will be rejected, so we need to choose a backlog size based on how fast our application is at accepting connections. In practice this is rarely a problem. Even if you used the value of 1 you probably wouldn't notice until you stress-tested your server.

Windows

Windows users will need to set up the process's socket context before any of the socket-related functions are called. This is done by calling WSAStartup():

1WSADATA wd;
2if (WSAStartup(MAKEWORD(2, 2), &wd))
3 return;
4

when the process is done using the network, it should call WSACleanup:

1WSACleanup();
2

On windows the socket type is not int but SOCKET, the function for closing sockets it closesocket, and the invalid value for a socket handle is not -1 but INVALID_SOCKET.

Putting Everything Together

Now let's put everything together and include the proper headers:

1#ifdef _WIN32
2#include <winsock2.h> // WSAStartup, WSACleanup, socket, bind, listen, closesocket
3#include <ws2tcpip.h> // inet_pton
4#define CLOSE_SOCKET closesocket
5#else
6#include <unistd.h> // close
7#include <sys/socket.h> // socket, bind, listen
8#include <arpa/inet.h> // htons
9#define SOCKET int
10#define INVALID_SOCKET -1
11#define CLOSE_SOCKET close
12#endif
13
14int main(void)
15{
16#ifdef _WIN32
17 WSADATA wd;
18 if (WSAStartup(MAKEWORD(2, 2), &wd))
19 return -1;
20#endif
21
22 SOCKET listen_fd = socket(AF_INET, SOCK_STREAM, 0);
23 if (listen_fd == INVALID_SOCKET) {
24 return -1;
25 }
26
27 int one = 1;
28 setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, (char*) &one, sizeof(one));
29
30 // We want to listen for connections on this interface and port
31 char addr[] = "127.0.0.1";
32 int port = 8080;
33
34 struct sockaddr_in bind_buf;
35 bind_buf.sin_family = AF_INET;
36 bind_buf.sin_port = htons(port);
37 if (inet_pton(AF_INET, addr, &bind_buf.sin_addr) != 1) {
38 CLOSE_SOCKET(listen_fd);
39 return -1;
40 }
41 if (bind(listen_fd, (struct sockaddr*) &bind_buf, sizeof(bind_buf)) < 0) {
42 CLOSE_SOCKET(listen_fd);
43 return -1;
44 }
45
46 if (listen(listen_fd, 32) < 0) {
47 CLOSE_SOCKET(listen_fd);
48 return -1;
49 }
50
51 // TODO: Accept and process connections
52
53
54#ifdef _WIN32
55 WSACleanup();
56#endif
57 return 0;
58}
59

This code will work automatically on Windows and Linux by detecting the platform based on if _WIN32 is defined or not.

Building the Project

Let's say the code is in the main.c file. To compile the program on Linux, open the terminal and run this command:

gcc -o main main.c

if you are on Windows, this is the command you must run:

gcc -o main.exe main.c -lws2_32

the last flag tells the linker we are using the windows socket module.

What's next

In the next post we'll introduce the accept loop! The loop where our server will spend most of its time accepting incoming connections and processing them.

Join the Discussion!

Have questions or feedback for me? Feel free to pop in my discord

Join the Discord Server