Single Threaded TCP Server from Scratch in Rust

Photo by Jonathan on Unsplash

Single Threaded TCP Server from Scratch in Rust

You can find the github repo here

In this article, we will build a single-threaded TCP server from scratch using Rust. We will explore the different kernel-level system calls involved in creating a TCP server. We won't use standard libraries like net or TcpStream. Instead, we'll use libc. This server will handle multiple client connections and print any received data.

Let's begin by understanding what a TCP server is: A TCP server is a server that uses Transmission Control Protocol (TCP) to receive and send data to clients over a network. It operates using a three-step process known as the 3-way handshake: SYN (Synchronize), SYN-ACK (Synchronize-Acknowledge), and ACK (Acknowledge).

I will introduce the socket() system call. It takes three arguments: domain, type and protocol. socket() returns a socket descriptor that can be used in later system calls, or it returns -1 if there is an error.

  • domain: to specify whether to use IPv4 or IPv6

  • type: to specify whether to use stream or datagram

  • protocol: to specify whether to use TCP or UDP

In Rust we can import it from libc, and use it as:

use libc::{self, socket, AF_INET, SOCK_STREAM, IPPROTO_TCP} 

fn main() {
    let socket_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if socket_fd == -1 {
        println!("Error creating socket");
    }
}

In the above code we specify to use IPv4 by passing parameter AF_INET, use stream by passing parameter SOCK_STREAM, and use TCP by passing parameter IPPROTO_TCP.

By default, sockets operate in blocking mode. This means that a program will wait for an operation to complete before proceeding to the next task. For example, if we attempt to read data from a client while in blocking mode, the program will pause execution until data is received. If the client has no data to send, the program waits indefinitely. This behavior can prevent the server from handling other connections efficiently.

To avoid this, we can set sockets to non-blocking mode using the fcntl() system call. In non-blocking mode, socket operations return immediately instead of waiting, allowing the program to continue executing other tasks.

This is how you can do that:


        let flags = fcntl(socket_fd, libc::F_GETFL);
        if flags == -1 {
            println!("Error getting socket flags");
        } else {
            if fcntl(socket_fd, F_SETFL, flags | O_NONBLOCK) == -1 {
                println!("Error setting 0_NONBLOCK")
            }
        }

Next we need to bind this socket to a particular port on our machine. This can be done by using bind() system call. It requires 3 arguments: socket file descriptor, address and address length. libc create in Rust provides a struct sockaddr_in to represent an IP Address. Structure of sockaddr_in as follows:

#[repr(C)]
pub struct sockaddr_in {
    pub sin_family: u16,   // Address family (AF_INET for IPv4)
    pub sin_port: u16,     // Port number (in network byte order)
    pub sin_addr: in_addr, // IP address
    pub sin_zero: [u8; 8], // Padding to match sockaddr size
}

Now we can use this in our code as:

let ip = "0.0.0.0";
let port = 8080;

let mut addr: sockaddr_in = std::mem::zeroed();
addr.sin_family = AF_INET as u16;
addr.sin_port = htons(port);
addr.sin_addr = convert_ip_to_in_addr(ip);

let bind_result = bind(
    socket_fd,
    &addr as *const sockaddr_in as *const libc::sockaddr,
    std::mem::size_of::<sockaddr_in>() as u32,
);
if bind_result == -1 {
    libc::perror(b"Error in binding\0".as_ptr() as *const i8);
    return;
}
println!("Binding Result: {}", bind_result);

Code for convert_ip_to_in_addr():

fn convert_ip_to_in_addr(ip: &str) -> libc::in_addr {
    let octets: Vec<u8> = ip
        .split('.')
        .map(|octet| octet.parse::<u8>().expect("Invalid octet"))
        .collect();

    libc::in_addr {
        s_addr: ((octets[0] as u32) << 24)
            | ((octets[1] as u32) << 16)
            | ((octets[2] as u32) << 8)
            | (octets[3] as u32),
    }
}

Next we need to use listen() and accept() system calls to listen and accept connections. listen() takes 2 arguments: socket file descriptor and backlog. backlog is the number of connections that can wait in incoming queue before you accept them.

let listen_result = listen(socket_fd, 1);
if listen_result == -1 {
    println!("Error in listen!");
    return;
}
println!("Server is listening for connections...");

Next, we create a vector named client_fds of type i32. We initialize it with socket_fd and will push the client's socket descriptor into it.

let mut client_fds: Vec<i32> = vec![socket_fd];

Next, we enter an infinite loop and create another vector named read_fds, which will contain socket descriptors from which we can read data. We also calculate max_fd among these socket descriptors. Then we use the select() system call, which helps us identify the client sockets ready to send data, so we don’t have to wait for them. It takes 5 arguments: total descriptors, a mutable pointer to read_fds, a pointer to write_fds, a pointer to error_fds, and a timeout. For now, to keep it simple, we will not maintain write_fds and error_fds.

let mut read_fds: fd_set = std::mem::zeroed();
FD_ZERO(&mut read_fds);

let mut max_fd = socket_fd;
for &fd in &client_fds {
    FD_SET(fd, &mut read_fds);
    if fd > max_fd {
        max_fd = fd;
    }
}

let timeout = timeval {
    tv_sec: 5,
    tv_usec: 0,
};

let ready_count = select(
    max_fd + 1,
    &mut read_fds,
    std::ptr::null_mut(),
    std::ptr::null_mut(),
    &timeout as *const timeval as *mut timeval,
);

if ready_count == -1 {
    println!("Error in select!");
    break;
}

if ready_count == 0 {
    println!("no clients yet");
}

Next, we check if there is any socket ready to connect, then we use accept() system call to accept connection and also push it into client_fds.

if FD_ISSET(socket_fd, &read_fds) {
     let client_sock = accept(socket_fd, std::ptr::null_mut(), std::ptr::null_mut());
     if client_sock != -1 {
        println!("Client connected {}", client_sock);
        client_fds.push(client_sock);
    }
}

Next, we iterate over every client in client_fds, and use recv() system call to read data from it, and if we receive any bytes we simply print them.

for &client in &client_fds {
    if client != socket_fd && FD_ISSET(client, &read_fds) {
    let mut buffer = [0u8; 1024];
    let bytes_received = recv(
        client,
        buffer.as_mut_ptr() as *mut libc::c_void,
        buffer.len(),
        0,
     );

     if bytes_received == -1 {
         let errno_val = errno().0;
         if errno_val == libc::EAGAIN || errno_val == libc::EWOULDBLOCK {
             println!("No data available yet, will try again later");
         } else {
             println!("Error receiving data: {}", errno_val);
         }
         } else if bytes_received > 0 {
             let received_data = String::from_utf8_lossy(&buffer[..bytes_received as usize]);
             println!("Received from {}: {}", client, received_data);
         }
    }
}

Now we can accept multiple connections simultaneously on our single threaded web server.

Drop a like if you found this helpful. Stay tuned for the next article.