Goal: a tiny, correct, blocking TCP echo server in C you can use as a baseline for later labs (forked/threaded models, select, and epoll). The point isn’t the feature list; it’s the control and measurement you’ll apply to everything that follows in posts to come shortly…

This series is a practical backbone for systems roles and lays foundations for deep infra: evented I/O, perf thinking, and OS hygiene.


Why start with an echo server?

An echo server sends back whatever it receives. That sounds trivial, but it’s ideal for:

  • Learning sockets and I/O models without protocol noise.
  • Plumbing checks (NAT, firewalls, proxies, Ingress).
  • Perf experiments (throughput/latency, syscalls, 10k-connections problem).
  • Debug repros (half-closes, TCP_NODELAY, buffer sizes).

Security note: The historical Echo Protocol (TCP/UDP port 7) is “trivial to abuse” for reflection and feedback loops (e.g., Echo↔Chargen). Don’t expose an echo server publicly unless you lock it down (TCP-only, allowlists, rate limits, timeouts, buffer caps, no verbose payload logs).


What is a blocking server?

A blocking server uses I/O calls that wait until they complete:

  • accept() blocks until a client connects.
  • read() blocks until data arrives (or peer closes).
  • write() can block if the socket’s send buffer is full.

In this baseline, the main thread does:

a c c e p t ( ) r e a d ( ) w r i t e ( ) c l o s e ( ) b a c k t o a c c e p t ( )

=> One client at a time; others queue in the listen backlog. It’s simple and a great reference point, but it won’t scale. That’s the point: a clean baseline to compare against threads/processes and then select/epoll.


The Code – Build, run, test

Project layout for Day‑1: linux-net-labs/01-echo-io-models/blocking/

The Day‑1 code is at linux-net-labs/01-echo-io-models/blocking/.

1
2
3
4
5
6
chmod +x run.sh
./run.sh
# In another terminal:
nc 127.0.0.1 8080
hello
hello

Exercise: Try opening a second or third terminal and connecting to it simultaneously with netcat as above. IT WON’T WORK!!

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
/*
 * server_blocking.c — Day 1: single-client blocking echo server
 *
 * Run:    ./server_blocking [port]    (default 8080)
 * Test:   nc 127.0.0.1 8080
 *
 * Signals: SIGINT/SIGTERM trigger clean shutdown.
 * Limits:  Accepts one client at a time; next client blocks at accept().
 *
 * WHY: Minimal baseline to compare against forked/select/epoll in later labs.
 */

#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

// Use this to experiment and log various things to help learning. Feel free to
// add more logs as you see fit.
#define LOG(fmt, ...) fprintf(stderr, "[blocking] " fmt "\n", ##__VA_ARGS__)

static volatile sig_atomic_t g_stop = 0;

// WHY: Use sigaction *without* SA_RESTART so blocking syscalls return EINTR.
//      This lets the main loop notice g_stop promptly on Ctrl-C/TERM.
static void on_stop(int _) { 
    (void)_; g_stop = 1; 
}

static void install_signals(void) {
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = on_stop;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;                // <-- no SA_RESTART
    sigaction(SIGINT,  &sa, NULL);
    sigaction(SIGTERM, &sa, NULL);  // allow `kill -TERM` too
}
int main(int argc, char **argv) {
    int port = (argc > 1) ? atoi(argv[1]) : 8080; // default port

    install_signals(); // setup Ctrl-C/TERM handler

    // opens a TCP endpoint and returns its file descriptor,
    // lfd=listen file descriptor
    int lfd = socket(AF_INET, SOCK_STREAM, 0);  
    if (lfd < 0) { perror("socket"); return 1; } // create socket

    // NOTE: Enables quick rebind after restarts (TIME_WAIT); not multi-bind magic.
    int yes = 1;
    if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) < 0) {
        perror("setsockopt"); return 1;
    }

    // bind to the given port on any/all local IPs
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(port);

    // bind the socket to the address/port
    if (bind(lfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { 
        perror("bind"); return 1; 
    }

    // start listening for incoming connections (max 128 queued clients)
    if (listen(lfd, 128) < 0) { 
        perror("listen"); return 1; 
    }

    fprintf(stderr, "[blocking] listening on :%d (Ctrl-C to stop)\n", port);

    // main loop: accept, echo, close, repeat
    while (!g_stop) {
        // accept a new client
        struct sockaddr_in cli; socklen_t clilen = sizeof(cli);

        // accept() blocks until a new client connects. cfd=client file descriptor
        int cfd = accept(lfd, (struct sockaddr*)&cli, &clilen); 

        // WHY: accept() returns EINTR if interrupted by signal (e.g. Ctrl-C).
        if (cfd < 0) {
            if (errno == EINTR) {           // interrupted by signal
                if (g_stop) break;          // exit cleanly
                continue;                   // else, retry
            }
            perror("accept");
            continue;
        }

        // log the new connection
        char ip[64]; inet_ntop(AF_INET, &cli.sin_addr, ip, sizeof(ip));
        fprintf(stderr, "client connected %s:%d\n", ip, ntohs(cli.sin_port));

        // echo loop: read until EOF, echo back what we received
        char buf[4096];
        for (;;) {
            ssize_t n = read(cfd, buf, sizeof(buf));
            if (n == 0) break;                 // client closed
            if (n < 0) {
                if (errno == EINTR) continue;     // retry short-term interrupts
                perror("read"); break;
            }
            // PERF: write() may be partial; loop until we've echoed all n bytes.
            // TCP is a byte stream; write may write fewer bytes than requested, 
            // so it loops until all n bytes are sent.
            ssize_t off = 0;            
            while (off < n) {
                ssize_t m = write(cfd, buf + off, n - off); //
                if (m < 0) { 
                    if (errno == EINTR) continue; 
                    perror("write"); 
                    goto done; 
                }
                off += m;
            }
        }
    done:
        close(cfd);      // ownership of cfd ends here
        fprintf(stderr, "client disconnected\n");
    }

    close(lfd);
    fprintf(stderr, "bye\n");
    return 0;
}

How It Works

This document explains how the provided server_blocking.c program works, line by line, and why it is structured the way it is.

High-level behavior

It’s a single-process, single-threaded blocking echo server over IPv4/TCP. It:

  1. Opens a listening socket on a port (default 8080).
  2. Waits (blocks) in accept() for a client.
  3. For that one client, repeatedly read()s bytes and immediately write()s them back (echo).
  4. When the client closes (or an error happens), it closes the connection and goes back to accept().
  5. Ctrl-C sets a flag that cleanly breaks the accept() loop and exits.

Because it’s blocking and handles one connection at a time, other clients queue in the listen backlog until the current client disconnects.


Includes and globals

  • <sys/socket.h>, <netinet/in.h>, <arpa/inet.h>: sockets, IPv4 structs, byte-order helpers.
  • <signal.h>: for catching SIGINT.
  • static volatile sig_atomic_t g_stop = 0;
    A signal-safe flag the handler can set asynchronously.
1
static void on_sigint(int _) { (void)_; g_stop = 1; }

The handler only sets a flag (no I/O), which is the correct pattern for signal safety.


Setup

1
2
int port = (argc > 1) ? atoi(argv[1]) : 8080;
install_signals(); 
  • Port comes from argv or defaults to 8080.
  • Registers the SIGINT handler so Ctrl-C requests a graceful shutdown.
1
int lfd = socket(AF_INET, SOCK_STREAM, 0);

Creates a TCP socket (IPv4, stream).

1
2
int yes = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));

Allows quick rebinding to the same port after restarts (avoids “address already in use” when a previous socket is in TIME_WAIT).

1
2
3
4
5
struct sockaddr_in addr = {
  .sin_family = AF_INET,
  .sin_addr.s_addr = htonl(INADDR_ANY),
  .sin_port = htons(port),
};
  • Binds to all local interfaces (0.0.0.0) on port.
  • htonl/htons convert host byte order → network byte order (big-endian), required for the socket API.
1
2
bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
listen(lfd, 128);
  • bind attaches the address to the socket.
  • listen(..., 128) starts the passive listen state; 128 is the backlog (max queued connections waiting for accept).

Main loop (accept → echo → close)

1
2
3
while (!g_stop) {
  struct sockaddr_in cli; socklen_t clilen = sizeof(cli);
  int cfd = accept(lfd, (struct sockaddr*)&cli, &clilen);
  • Blocking accept: the process sleeps here until a client connects.
  • If a SIGINT arrives, accept may fail with EINTR; code checks that to break cleanly.
1
2
3
char ip[64]; inet_ntop(AF_INET, &cli.sin_addr, ip, sizeof(ip));
fprintf(stderr, "client connected %s:%d
", ip, ntohs(cli.sin_port));
  • Formats the client’s IP/port for logs. ntohs converts the port back to host order.

Echo loop

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
char buf[4096];
for (;;) {
  ssize_t n = read(cfd, buf, sizeof(buf));
  if (n == 0) { break; }                 // peer closed the connection
  else if (n < 0) {
    if (errno == EINTR) continue;        // interrupted; retry
    perror("read"); break;               // real error
  }
  // write back exactly what we read
  ssize_t off = 0;
  while (off < n) {
    ssize_t m = write(cfd, buf + off, n - off);
    if (m < 0) {
      if (errno == EINTR) continue;      // interrupted; retry write
      perror("write"); goto done;        // real error
    }
    off += m;                            // handle partial writes
  }
}
done:
close(cfd);

Key points:

  • n == 0 means EOF: the peer performed an orderly shutdown (e.g., closed the socket).
  • Handles EINTR correctly for both read and write.
  • Handles partial writes: TCP is a byte stream; write may write fewer bytes than requested, so it loops until all n bytes are sent.

After the client session ends, it closes cfd and logs “client disconnected”.


Shutdown path

When you press Ctrl-C, the signal handler sets g_stop = 1. If accept is blocked, it is interrupted (EINTR), the code sees g_stop and breaks the loop, then:

1
2
3
close(lfd);
fprintf(stderr, "bye
");

Clean exit, releasing the port.


Why it’s called “blocking”

  • Blocking I/O: accept, read, and write all sleep the thread until progress can be made.
  • Single concurrency slot: While serving one client, the server is not calling accept, so others queue in the backlog. It’s simple and fine for demos or very low traffic; for many clients you’d fork/thread/event-loop.

Subtle correctness wins in this code

  • Uses volatile sig_atomic_t and a minimal signal handler.
  • Checks EINTR everywhere it matters.
  • Handles partial writes.
  • Uses network/host byte-order helpers.
  • Logs client IP/port with inet_ntop.

Possible extensions

  • IPv6 & dual-stack: use getaddrinfo and bind both v4/v6 (or v6-only with IPV6_V6ONLY=0 where supported).
  • sigaction instead of signal() for portable handler semantics (set SA_RESTART if you prefer syscalls to auto-retry).
  • Concurrency: fork per connection, thread per connection, or switch to non-blocking + select/poll/epoll/kqueue.
  • Backpressure/limits: timeouts with SO_RCVTIMEO/SO_SNDTIMEO, connection caps, etc.
  • Error paths: more precise logging; handle setsockopt/inet_ntop failures.

Gotchas & notes

  • EINTR vs SA_RESTART: if syscalls restart automatically, Ctrl‑C won’t stop a blocking accept(). Use sigaction with no SA_RESTART or siginterrupt(SIGINT, 1).
  • Partial writes: write() may send fewer bytes than requested; loop until you send all n bytes.
  • SO_REUSEADDR: helps quick restarts while sockets linger in TIME_WAIT; behavior differs across OSes.
  • SIGPIPE: writing to a dead peer can raise SIGPIPE (default: terminate). Common hardening: signal(SIGPIPE, SIG_IGN) or send(..., MSG_NOSIGNAL).
  • Limits: This baseline serves one client at a time. That’s deliberate—to highlight improvements later.

TL;DR

It’s a clean, correct example of a blocking, single-client echo server:
set up socket → bind/listen → loop on accept → for each client, readwrite back the same bytes → close → repeat; exit gracefully on Ctrl-C.


Roadmap (what’s next)

  • Day‑2: forked multi‑client server (one child per connection). Quick strace -f -c to see process cost.
  • Week‑1 (cont.): select() multiplexing, then epoll (level‑triggered) for the 10k‑connection problem.
  • Week‑2: epoll (edge‑triggered) + write buffering; TCP_NODELAY vs TCP_CORK.
  • Week‑3: Threads vs small thread‑pool + SO_REUSEPORT multi‑acceptors; CPU affinity.
  • Week‑4: Static HTTP with sendfile() vs read/write; measure syscalls and throughput.
  • Week‑5: cgroups v2 limits (CPU/memory) on your server; show throttling graphs.
  • Week‑6: Hardening: seccomp‑bpf, capabilities, user/net namespaces; systemd units & health checks.

Definition of Done (for each lab)

  • Build: one command (make / ./run.sh).
  • Tests: quick nc check + a small load poke (wrk/ab) later.
  • Observability: at least one strace -c or perf stat table.
  • Limits & failure modes documented.
  • Short write‑up with results/graphs in results/.

Appendix: Windows differences (curiosity)

  • No fork() on Windows; process-per-connection isn’t idiomatic. Use threads or IOCP (I/O Completion Ports) with overlapped I/O.
  • Winsock vs POSIX: recv/send/closesocket, WSAStartup, ioctlsocket(FIONBIO) for nonblocking. For this series we focus on Linux because the container/cloud/Kubernetes ecosystem and most infra tooling are optimized for it.

If you’re following along in the repo: the Day‑1 code is at linux-net-labs/01-echo-io-models/blocking/.

See also: Day 2: Multi-Client Forking Echo Server.