TCP Client-Server Programming

The previous chapter showed the individual socket calls. Now we wire them into real programs: an echo server that handles multiple clients, a matching client, graceful shutdown, and protocol framing. By the end, you will have a working chat server.

The Echo Server in C

This server accepts connections in a loop, forks a child process for each client, and echoes back everything it receives.

/* echo_server.c -- fork-per-connection echo server */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>

static volatile sig_atomic_t running = 1;

static void handle_sigterm(int sig)
{
    (void)sig;
    running = 0;
}

static void reap_children(int sig)
{
    (void)sig;
    while (waitpid(-1, NULL, WNOHANG) > 0)
        ;
}

static void handle_client(int fd)
{
    char buf[4096];
    ssize_t n;
    while ((n = read(fd, buf, sizeof(buf))) > 0) {
        ssize_t written = 0;
        while (written < n) {
            ssize_t w = write(fd, buf + written, n - written);
            if (w <= 0) return;
            written += w;
        }
    }
    close(fd);
}

int main(void)
{
    struct sigaction sa_term;
    memset(&sa_term, 0, sizeof(sa_term));
    sa_term.sa_handler = handle_sigterm;
    sigaction(SIGTERM, &sa_term, NULL);
    sigaction(SIGINT, &sa_term, NULL);

    struct sigaction sa_chld;
    memset(&sa_chld, 0, sizeof(sa_chld));
    sa_chld.sa_handler = reap_children;
    sa_chld.sa_flags   = SA_RESTART;
    sigaction(SIGCHLD, &sa_chld, NULL);

    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) { perror("socket"); return 1; }

    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr = {0};
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port        = htons(7878);

    if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind"); return 1;
    }
    if (listen(listen_fd, 128) < 0) {
        perror("listen"); return 1;
    }
    printf("Echo server listening on port 7878\n");

    while (running) {
        struct sockaddr_in client;
        socklen_t clen = sizeof(client);
        int conn_fd = accept(listen_fd, (struct sockaddr *)&client, &clen);
        if (conn_fd < 0) {
            if (errno == EINTR) continue;
            perror("accept");
            break;
        }

        char ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &client.sin_addr, ip, sizeof(ip));
        printf("New connection from %s:%d\n", ip, ntohs(client.sin_port));

        pid_t pid = fork();
        if (pid < 0) {
            perror("fork");
            close(conn_fd);
        } else if (pid == 0) {
            /* Child: handle client */
            close(listen_fd);
            handle_client(conn_fd);
            _exit(0);
        } else {
            /* Parent: close the connected fd, keep listening */
            close(conn_fd);
        }
    }

    printf("\nShutting down...\n");
    close(listen_fd);
    return 0;
}

SIGCHLD handler reaps zombie processes. accept() can return EINTR when interrupted; the loop retries. The child closes the listening socket; the parent closes the connected socket.

Caution: fork() duplicates the entire process. A thousand simultaneous connections means a thousand processes. We will fix this shortly.

The Matching Client

/* echo_client.c -- send lines from stdin, print echoed replies */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netdb.h>

int main(int argc, char *argv[])
{
    const char *host = argc > 1 ? argv[1] : "127.0.0.1";
    const char *port = argc > 2 ? argv[2] : "7878";

    struct addrinfo hints = {0}, *res;
    hints.ai_family   = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;

    int s = getaddrinfo(host, port, &hints, &res);
    if (s != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(s));
        return 1;
    }

    int fd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
    if (fd < 0) { perror("socket"); return 1; }

    if (connect(fd, res->ai_addr, res->ai_addrlen) < 0) {
        perror("connect"); return 1;
    }
    freeaddrinfo(res);

    printf("Connected. Type lines to echo (Ctrl-D to quit):\n");

    char line[1024];
    while (fgets(line, sizeof(line), stdin)) {
        write(fd, line, strlen(line));

        char buf[1024];
        ssize_t n = read(fd, buf, sizeof(buf) - 1);
        if (n <= 0) break;
        buf[n] = '\0';
        printf("echo: %s", buf);
    }

    close(fd);
    return 0;
}

Try It: Start the server, then open three separate terminals each running the client. Verify that all three sessions echo independently.

Thread-Per-Connection Alternative

Threads share the same address space, so they are cheaper than processes. Replace the fork() block with pthread_create.

/* echo_server_threaded.c -- thread-per-connection echo server */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

static void *client_thread(void *arg)
{
    int fd = *(int *)arg;
    free(arg);

    char buf[4096];
    ssize_t n;
    while ((n = read(fd, buf, sizeof(buf))) > 0) {
        ssize_t w = 0;
        while (w < n) {
            ssize_t ret = write(fd, buf + w, n - w);
            if (ret <= 0) goto done;
            w += ret;
        }
    }
done:
    close(fd);
    return NULL;
}

int main(void)
{
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) { perror("socket"); return 1; }

    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr = {0};
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port        = htons(7878);

    if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind"); return 1;
    }
    listen(listen_fd, 128);
    printf("Threaded echo server on port 7878\n");

    for (;;) {
        struct sockaddr_in client;
        socklen_t clen = sizeof(client);
        int conn_fd = accept(listen_fd, (struct sockaddr *)&client, &clen);
        if (conn_fd < 0) { perror("accept"); continue; }

        int *fdp = malloc(sizeof(int));
        *fdp = conn_fd;

        pthread_t tid;
        if (pthread_create(&tid, NULL, client_thread, fdp) != 0) {
            perror("pthread_create");
            close(conn_fd);
            free(fdp);
        } else {
            pthread_detach(tid);
        }
    }

    close(listen_fd);
    return 0;
}

Compile with gcc -pthread echo_server_threaded.c -o echo_server_threaded.

Caution: We heap-allocate the fd so each thread gets its own copy. Passing &conn_fd directly is a race: the main loop may overwrite conn_fd before the thread reads it.

Protocol Framing

TCP is a byte stream. If the client sends two messages quickly, the server might receive them glued together in one read() call. You need a protocol to know where one message ends and the next begins.

Two common approaches: (1) length-prefix -- send 4 bytes of length then the payload; (2) delimiter -- terminate each message with \n. Length-prefix is more robust.

Length-Prefix Framing in C

/* framed_send.c -- send a length-prefixed message */
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdint.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>

/* Write exactly n bytes */
static int write_all(int fd, const void *buf, size_t n)
{
    const char *p = buf;
    while (n > 0) {
        ssize_t w = write(fd, p, n);
        if (w <= 0) return -1;
        p += w;
        n -= w;
    }
    return 0;
}

/* Read exactly n bytes */
static int read_all(int fd, void *buf, size_t n)
{
    char *p = buf;
    while (n > 0) {
        ssize_t r = read(fd, p, n);
        if (r <= 0) return -1;
        p += r;
        n -= r;
    }
    return 0;
}

int send_message(int fd, const char *msg, size_t len)
{
    uint32_t net_len = htonl((uint32_t)len);
    if (write_all(fd, &net_len, 4) < 0) return -1;
    if (write_all(fd, msg, len) < 0)     return -1;
    return 0;
}

int recv_message(int fd, char *buf, size_t bufsize, size_t *out_len)
{
    uint32_t net_len;
    if (read_all(fd, &net_len, 4) < 0) return -1;
    uint32_t len = ntohl(net_len);
    if (len > bufsize) return -1;   /* message too large */
    if (read_all(fd, buf, len) < 0) return -1;
    *out_len = len;
    return 0;
}

Caution: Always validate the length prefix. A malicious client could send 0xFFFFFFFF and trick you into allocating 4 GB of memory. Set a maximum message size.

Try It: Write a small main() that connects to the echo server, sends a framed message, then reads the framed response. Verify that even rapid sends are correctly separated.

Rust TCP Server

// echo_server.rs -- multi-threaded echo server in Rust
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;

fn handle_client(mut stream: TcpStream) {
    let peer = stream.peer_addr().unwrap();
    println!("Connection from {}", peer);

    let mut buf = [0u8; 4096];
    loop {
        match stream.read(&mut buf) {
            Ok(0) => break,
            Ok(n) => {
                if stream.write_all(&buf[..n]).is_err() {
                    break;
                }
            }
            Err(_) => break,
        }
    }
    println!("{} disconnected", peer);
}

fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("0.0.0.0:7878")?;
    println!("Echo server listening on port 7878");

    for stream in listener.incoming() {
        match stream {
            Ok(s) => {
                thread::spawn(move || handle_client(s));
            }
            Err(e) => eprintln!("accept error: {}", e),
        }
    }
    Ok(())
}

Rust Note: TcpStream is Send, so moving it into a thread::spawn closure is safe. The compiler ensures no two threads can access the same stream. No malloc for the fd pointer, no detach -- ownership transfer handles everything.

Rust: Length-Prefix Framing

// framed.rs -- length-prefix framing over TCP
use std::io::{self, Read, Write};
use std::net::TcpStream;

fn send_message(stream: &mut TcpStream, msg: &[u8]) -> io::Result<()> {
    let len = (msg.len() as u32).to_be_bytes();
    stream.write_all(&len)?;
    stream.write_all(msg)?;
    Ok(())
}

fn recv_message(stream: &mut TcpStream) -> io::Result<Vec<u8>> {
    let mut len_buf = [0u8; 4];
    stream.read_exact(&mut len_buf)?;
    let len = u32::from_be_bytes(len_buf) as usize;

    if len > 1_000_000 {
        return Err(io::Error::new(io::ErrorKind::InvalidData,
                                  "message too large"));
    }

    let mut buf = vec![0u8; len];
    stream.read_exact(&mut buf)?;
    Ok(buf)
}

fn main() -> io::Result<()> {
    let mut stream = TcpStream::connect("127.0.0.1:7878")?;
    send_message(&mut stream, b"Hello, framed world!")?;

    let reply = recv_message(&mut stream)?;
    println!("Got: {}", String::from_utf8_lossy(&reply));
    Ok(())
}

read_exact loops internally until exactly N bytes are read. This eliminates the manual read_all loop from C.

A Complete Chat Server in C

This is the culmination: a multi-client chat server where messages from one client are broadcast to all others.

/* chat_server.c -- simple broadcast chat (thread-per-connection) */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define MAX_CLIENTS 64
#define BUF_SIZE    1024

static pthread_mutex_t clients_lock = PTHREAD_MUTEX_INITIALIZER;
static int client_fds[MAX_CLIENTS];
static int client_count = 0;

static void add_client(int fd)
{
    pthread_mutex_lock(&clients_lock);
    if (client_count < MAX_CLIENTS) {
        client_fds[client_count++] = fd;
    }
    pthread_mutex_unlock(&clients_lock);
}

static void remove_client(int fd)
{
    pthread_mutex_lock(&clients_lock);
    for (int i = 0; i < client_count; i++) {
        if (client_fds[i] == fd) {
            client_fds[i] = client_fds[--client_count];
            break;
        }
    }
    pthread_mutex_unlock(&clients_lock);
}

static void broadcast(int sender_fd, const char *msg, size_t len)
{
    pthread_mutex_lock(&clients_lock);
    for (int i = 0; i < client_count; i++) {
        if (client_fds[i] != sender_fd) {
            write(client_fds[i], msg, len);
        }
    }
    pthread_mutex_unlock(&clients_lock);
}

static void *client_thread(void *arg)
{
    int fd = *(int *)arg;
    free(arg);
    add_client(fd);

    char buf[BUF_SIZE];
    ssize_t n;
    while ((n = read(fd, buf, sizeof(buf))) > 0) {
        broadcast(fd, buf, n);
    }

    remove_client(fd);
    close(fd);
    return NULL;
}

int main(void)
{
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr = {0};
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port        = htons(9000);

    bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(listen_fd, 128);
    printf("Chat server on port 9000 (use: nc 127.0.0.1 9000)\n");

    for (;;) {
        struct sockaddr_in cl;
        socklen_t len = sizeof(cl);
        int conn = accept(listen_fd, (struct sockaddr *)&cl, &len);
        if (conn < 0) continue;

        char ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &cl.sin_addr, ip, sizeof(ip));
        printf("%s:%d joined\n", ip, ntohs(cl.sin_port));

        int *fdp = malloc(sizeof(int));
        *fdp = conn;
        pthread_t tid;
        pthread_create(&tid, NULL, client_thread, fdp);
        pthread_detach(tid);
    }
}

Test with multiple nc 127.0.0.1 9000 sessions. Type in one and watch it appear in the others.

Rust Chat Server

// chat_server.rs -- broadcast chat server
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream, SocketAddr};
use std::sync::{Arc, Mutex};
use std::thread;

type ClientList = Arc<Mutex<Vec<(SocketAddr, TcpStream)>>>;

fn handle_client(stream: TcpStream, clients: ClientList) {
    let peer = stream.peer_addr().unwrap();
    println!("{} joined", peer);

    { clients.lock().unwrap().push((peer, stream.try_clone().unwrap())); }

    let reader = BufReader::new(stream);
    for line in reader.lines().flatten() {
        let full = format!("{}: {}\n", peer, line);
        let list = clients.lock().unwrap();
        for (addr, mut s) in list.iter().filter(|(a, _)| *a != peer) {
            let _ = s.write_all(full.as_bytes());
        }
    }

    { clients.lock().unwrap().retain(|(a, _)| *a != peer); }
    println!("{} left", peer);
}

fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("0.0.0.0:9000")?;
    let clients: ClientList = Arc::new(Mutex::new(Vec::new()));
    println!("Chat server on port 9000");

    for stream in listener.incoming() {
        let stream = stream?;
        let clients = Arc::clone(&clients);
        thread::spawn(move || handle_client(stream, clients));
    }
    Ok(())
}

Rust Note: Arc<Mutex<Vec<TcpStream>>> is the standard pattern for shared mutable state across threads. The compiler refuses to compile the program if you try to share without proper synchronization. No equivalent compile-time guarantee exists in C.

Driver Prep: Kernel network drivers process packets without the luxury of threads-per-connection. The patterns in chapters 48 and 49 (poll, epoll) are what drivers and high-performance servers use instead.

Graceful Shutdown Pattern

The volatile sig_atomic_t running flag (shown in the fork server above) is the standard approach. The signal handler sets it to 0; the main loop checks it. Close the listening socket to unblock accept(), then wait for in-flight clients to finish before exiting.

Knowledge Check

  1. Why does the fork-based server close conn_fd in the parent and listen_fd in the child?
  2. What happens if you omit the mutex around the client list in the chat server?
  3. How does length-prefix framing solve the TCP message-boundary problem?

Common Pitfalls

  • Not handling partial writes -- write() can return fewer bytes than requested. Always loop.
  • Not handling partial reads -- same issue on the receive side. read() returns whatever is available, not a complete message.
  • Zombie processes -- forgetting SIGCHLD handler with fork-per-connection fills the process table.
  • Thread stack overflow -- each thread allocates a stack (typically 2-8 MB). Thousands of threads consume gigabytes of memory.
  • Broadcasting while holding the lock too long -- a slow client's write() can block, stalling all other broadcasts. Consider non-blocking I/O or per-client queues.
  • Forgetting SO_REUSEADDR -- restarting the server gives "Address already in use" for up to 60 seconds.