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
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:
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.
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;
}
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().
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();
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
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.
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.
exit() flushes stdio buffers (causing duplicate output) and runs atexit() handlers registered by the parent (causing double cleanup).
Multiple children may die between SIGCHLD deliveries. A single waitpid() reaps only one. Use a while loop.
After fork(): parent closes cfd, child closes lfd. Child uses _exit(). SIGCHLD handler uses waitpid() in a while loop. All errors go to syslog.
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).
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.
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.
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.
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.
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.
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.
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.
