TCP Echo Server — Full Implementation Linux Socket Programming

 

TCP Echo Server — Full Implementation
Chapter 60 — Part 2 of 4 • Linux Socket Programming
📜 Code Walkthrough
📈 Flow Diagrams
🔍 Interview Q&A

What You Will Learn

In this part you will see the complete TCP echo server code from TLPI Chapter 60, broken down section by section. Each function is explained in plain English. You will understand why every line exists, what would happen if it were removed, and how data flows between client and server.

Key Terms

TCP Echo Service Port 7 inetListen() handleRequest() grimReaper() syslog() becomeDaemon() SIGCHLD EOF Detection sigaction()

💬 What Is the TCP Echo Service?

The echo service is one of the simplest TCP services defined in the early days of the internet (RFC 862). It runs on port 7. Its job is trivially simple:

Echo Service Rule: Whatever bytes the client sends, the server sends exactly the same bytes back. Nothing more, nothing less.

Despite being simple, the echo server is an excellent teaching tool because:

  • It demonstrates bidirectional data flow on a TCP socket.
  • It shows how to detect when the client disconnects (EOF on read).
  • It requires a concurrent server design because a client may send unlimited data.

TCP Echo Data Flow
CLIENT
Sends “Hello”
▶▶ “Hello” ▶▶
◄◄ “Hello” ◄◄
ECHO SERVER
Sends back “Hello”

💻 The Complete Echo Server — Annotated Line by Line

Here is the full server implementation, written to be easy to understand. Comments explain the why, not just the what.

#include <signal.h>
#include <syslog.h>       /* For syslog() - daemon logging */
#include <sys/wait.h>     /* For waitpid() */
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define SERVICE_PORT  7       /* TCP echo service port */
#define BUF_SIZE      4096    /* Read/write buffer size */

/*
 * grimReaper - SIGCHLD handler
 *
 * Called whenever a child process terminates.
 * Reaps ALL dead children using a non-blocking loop.
 * Must save/restore errno because waitpid() can change it.
 */
static void grimReaper(int sig)
{
    int savedErrno = errno;
    while (waitpid(-1, NULL, WNOHANG) > 0)
        continue;
    errno = savedErrno;
}

/*
 * handleRequest - handles one client connection
 *
 * Called in the child process.
 * Reads data from the connected socket (cfd) and
 * writes it back. Continues until client disconnects
 * (read returns 0) or an error occurs.
 */
static void handleRequest(int cfd)
{
    char buf[BUF_SIZE];
    ssize_t numRead;

    while ((numRead = read(cfd, buf, BUF_SIZE)) > 0) {
        /*
         * read() returns number of bytes received.
         * write() must send all of them back.
         * If write() sends fewer bytes than expected, log and exit.
         */
        if (write(cfd, buf, numRead) != numRead) {
            syslog(LOG_ERR, "write() failed: %s", strerror(errno));
            exit(EXIT_FAILURE);
        }
    }

    /*
     * numRead == 0: client closed the connection (EOF).
     *              We just return - the child will close cfd and exit.
     * numRead == -1: error occurred while reading.
     *               Log it and exit with failure.
     */
    if (numRead == -1) {
        syslog(LOG_ERR, "Error from read(): %s", strerror(errno));
        exit(EXIT_FAILURE);
    }
}

int main(int argc, char *argv[])
{
    int lfd;                   /* Listening socket file descriptor */
    int cfd;                   /* Connected socket file descriptor */
    struct sigaction sa;
    struct sockaddr_in serverAddr;

    /*
     * Step 1: Become a daemon.
     * This detaches from the terminal, changes CWD to /,
     * redirects stdin/stdout/stderr to /dev/null, and
     * makes the process a new session leader.
     */
    /* becomeDaemon(0); */  /* Commented out here for clarity in example */

    /*
     * Step 2: Install SIGCHLD handler.
     * SA_RESTART: automatically restart system calls interrupted by signal.
     * sigemptyset: no additional signals blocked during handler.
     */
    sigemptyset(&sa.sa_mask);
    sa.sa_flags   = SA_RESTART;
    sa.sa_handler = grimReaper;
    if (sigaction(SIGCHLD, &sa, NULL) == -1) {
        syslog(LOG_ERR, "Error from sigaction(): %s", strerror(errno));
        exit(EXIT_FAILURE);
    }

    /*
     * Step 3: Create the TCP listening socket.
     * AF_INET  = IPv4
     * SOCK_STREAM = TCP (reliable, ordered, connection-based)
     */
    lfd = socket(AF_INET, SOCK_STREAM, 0);
    if (lfd == -1) {
        syslog(LOG_ERR, "socket() failed: %s", strerror(errno));
        exit(EXIT_FAILURE);
    }

    /* Allow immediate reuse of the port after server restart */
    int opt = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    memset(&serverAddr, 0, sizeof(serverAddr));
    serverAddr.sin_family      = AF_INET;
    serverAddr.sin_port        = htons(SERVICE_PORT);
    serverAddr.sin_addr.s_addr = htonl(INADDR_ANY);  /* All interfaces */

    if (bind(lfd, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) == -1) {
        syslog(LOG_ERR, "bind() failed: %s", strerror(errno));
        exit(EXIT_FAILURE);
    }

    /*
     * listen() puts the socket into a passive state.
     * Backlog of 10: up to 10 connections can queue before accept() is called.
     */
    if (listen(lfd, 10) == -1) {
        syslog(LOG_ERR, "listen() failed: %s", strerror(errno));
        exit(EXIT_FAILURE);
    }

    /*
     * Step 4: The main accept() loop.
     * The server runs forever, accepting one client at a time,
     * then forking a child to handle it.
     */
    for (;;) {
        cfd = accept(lfd, NULL, NULL);   /* Block until a client connects */
        if (cfd == -1) {
            syslog(LOG_ERR, "Failure in accept(): %s", strerror(errno));
            exit(EXIT_FAILURE);
        }

        /* Step 5: Fork a child for this client */
        switch (fork()) {

        case -1:
            /*
             * fork() failed - probably out of memory or process limit hit.
             * Close this client and continue. It might work for the next one.
             */
            syslog(LOG_ERR, "Can't create child (%s)", strerror(errno));
            close(cfd);
            break;

        case 0:
            /*
             * CHILD PROCESS:
             * - Close the listening socket (child doesn't accept connections).
             * - Handle the client.
             * - Use _exit() (not exit()) to avoid flushing parent's stdio buffers.
             */
            close(lfd);
            handleRequest(cfd);
            _exit(EXIT_SUCCESS);

        default:
            /*
             * PARENT PROCESS:
             * - Close the connected socket (parent doesn't talk to this client).
             * - Loop back to accept() and wait for the next client.
             */
            close(cfd);
            break;
        }
    }
    /* Never reached */
    return 0;
}

🔎 Deep Dive: handleRequest() Function

The handleRequest() function runs inside the child process. Let us trace through every possible scenario:

Return value of read() Meaning Action Taken
Greater than 0 Data received from client write() the same bytes back; continue the loop
0 (EOF) Client closed its side of the connection Loop exits; return from handleRequest(); child calls _exit()
-1 (error) Network error, connection reset, etc. Log error with syslog(); exit(EXIT_FAILURE)

What happens when the client disconnects? When the client calls close() on its socket, the TCP stack sends a FIN segment to the server. The server’s read() returns 0 (just like reading end-of-file from a file). The server then returns from handleRequest(), the child closes cfd and calls _exit(). The kernel sends SIGCHLD to the parent, which reaps the child in grimReaper().

📥 Why Use syslog() Instead of fprintf(stderr)?

A daemon is detached from any terminal. Its stderr is redirected to /dev/null. If the server used fprintf(stderr, ...), all error messages would be silently discarded.

syslog() sends messages to the system log daemon (syslogd or journald), where they are stored persistently in /var/log/syslog or readable via journalctl.

/* syslog severity levels (most important first): */
syslog(LOG_EMERG,   "System is unusable");         /* 0 */
syslog(LOG_ALERT,   "Action must be taken now");   /* 1 */
syslog(LOG_CRIT,    "Critical condition");         /* 2 */
syslog(LOG_ERR,     "Error condition");            /* 3 */
syslog(LOG_WARNING, "Warning condition");          /* 4 */
syslog(LOG_NOTICE,  "Normal but significant");     /* 5 */
syslog(LOG_INFO,    "Informational message");      /* 6 */
syslog(LOG_DEBUG,   "Debug message");              /* 7 */

/* In a real server, open syslog first: */
openlog("myserver", LOG_PID | LOG_CONS, LOG_DAEMON);

/* Then log as needed: */
syslog(LOG_ERR, "accept() failed: %s", strerror(errno));

/* And close at the end (optional, called automatically on exit): */
closelog();

📋 Complete Server Lifecycle Diagram
Echo Server Full Lifecycle

SERVER MAIN (Parent)
becomeDaemon()
Install SIGCHLD handler
Create listening socket (lfd)
accept() — BLOCKS
Client connects → cfd returned
fork()
close(cfd) — parent done with this client
accept() — wait for next client

CHILD PROCESS
close(lfd)
handleRequest(cfd) — read/write loop
EOF from client → return
_exit() → SIGCHLD sent to parent

SIGCHLD HANDLER
waitpid(-1, NULL, WNOHANG) loop
Zombie reaped

🔧 How to Compile and Test

Here is how to compile the echo server and test it using standard tools:

# Compile
gcc -o echo_server echo_server.c

# Run (needs root for port 7, or use a port > 1024 like 9007)
./echo_server

# Test with netcat (nc)
echo "Hello Echo Server" | nc 127.0.0.1 9007

# Test with telnet
telnet 127.0.0.1 9007
# Then type anything and see it echoed back
# Press Ctrl+] then quit to exit

# Check the server is running
ps aux | grep echo_server

# Check the port is listening
ss -tlnp | grep 9007

# Watch syslog output
journalctl -f       # systemd systems
tail -f /var/log/syslog    # traditional syslog

🚫 Common Mistakes and How to Avoid Them
❌ Mistake 1: Not closing cfd in the parent

The connected socket is never actually closed. The client sees the connection as open even after the child exits. Eventually the parent runs out of file descriptors and accept() starts failing.

❌ Mistake 2: Not closing lfd in the child

The listening socket stays open in every child. With many clients, you quickly run out of file descriptors in the child processes. Also prevents the server from being restarted cleanly.

❌ Mistake 3: Using exit() instead of _exit() in the child

exit() flushes stdio buffers (causing duplicate output) and runs atexit() handlers registered by the parent (causing double cleanup).

❌ Mistake 4: Using a single waitpid() instead of a loop in the SIGCHLD handler

Multiple children may die between SIGCHLD deliveries. A single waitpid() reaps only one. Use a while loop.

✔ Correct Pattern Summary

After fork(): parent closes cfd, child closes lfd. Child uses _exit(). SIGCHLD handler uses waitpid() in a while loop. All errors go to syslog.

🎓 Interview Questions & Answers
Q1. What does the TCP echo service do and on which port does it run?

The TCP echo service reads everything a client sends and sends the exact same bytes back. It runs on port 7 (defined in /etc/services as “echo”). It operates until the client disconnects (sends EOF).

Q2. How does the server know the client has disconnected?

When the client calls close() on its socket, TCP sends a FIN segment to the server. The server’s read() call returns 0, which is the EOF indicator. The while loop exits and handleRequest() returns.

Q3. Why is the echo server a concurrent server rather than an iterative server?

A client may send data indefinitely. If the server is iterative, it would be blocked serving that one client forever, and no other client could connect. Concurrent design uses fork() so each client is handled by its own child process simultaneously.

Q4. Why is write() return value checked against numRead in handleRequest()?

A write() on a socket may write fewer bytes than requested (partial write), especially if the send buffer is full. If we do not check, we silently drop bytes that were not sent. The server must ensure all received bytes are echoed back.

Q5. What is the purpose of SO_REUSEADDR in a server socket?

Without SO_REUSEADDR, if you restart the server quickly after stopping it, bind() fails with “Address already in use”. This is because the old TCP connection may still be in TIME_WAIT state. SO_REUSEADDR allows the bind even if the port is in TIME_WAIT.

Q6. What does the listen() backlog parameter control?

The backlog is the maximum number of connections that can be queued waiting for accept() to be called. Connections beyond the backlog are silently dropped by the kernel. A backlog of 10 means up to 10 clients can be in the accept queue at any time.

Q7. Why does the SIGCHLD handler save and restore errno?

A signal handler can interrupt any system call. If waitpid() inside the handler changes errno (for example, to ECHILD when no children remain), and the handler runs while the main code was also about to read errno after another failed call, the original error code is lost. Saving and restoring errno prevents this bug.

Q8. What is the purpose of BUF_SIZE = 4096 in the echo server?

4096 bytes matches the typical Linux page size and is a good general-purpose read buffer. read() will read up to BUF_SIZE bytes per call. Multiple read/write iterations handle larger data transparently. The size affects throughput efficiency but not correctness.

Next: Preforked and Prethreaded Servers
Learn how high-performance servers use server pools to eliminate the per-connection fork overhead.

Part 3 → Preforked Servers ← Part 1

Leave a Reply

Your email address will not be published. Required fields are marked *