Procedural Programming with C · Advanced · Week 40

Network Programming with Sockets

A socket is a file descriptor, so everything from week 39 still applies — short transfers, EINTR, close. What is new is that the other end is a different machine, which makes byte order matter and makes message boundaries something you must invent yourself.

By the end of this week you can
  • Explain the client and server call sequences and what each step does.
  • Resolve a host and port with getaddrinfo rather than hard-coded structures.
  • Write a TCP client and a forking server that survive real network conditions.
  • Frame messages over a stream that has no message boundaries.
  • Handle partial transfers, timeouts, and a peer that disappears.

1The model

socket()connect() send() / recv()close() socket()bind() + listen() accept() — blockssend() / recv() connection request data, both directions clientserver

accept returns a new descriptor for the connection; the listening socket stays open for the next one.

TCP (SOCK_STREAM)UDP (SOCK_DGRAM)
ConnectionEstablished firstNone
DeliveryReliable, orderedBest effort; may be lost, duplicated, reordered
BoundariesNone — a byte streamPreserved per datagram
Used forHTTP, SSH, databasesDNS, video, games

The third row is the one that surprises people, and section 4 is about nothing else.

2Addresses

Do not fill in a struct sockaddr_in by hand. getaddrinfo resolves a host and service, handles IPv4 and IPv6 identically, and returns ready-made structures:

#include <sys/socket.h>
#include <netdb.h>

struct addrinfo hints;
memset(&hints, 0, sizeof hints);
hints.ai_family   = AF_UNSPEC;      /* IPv4 or IPv6, whichever resolves */
hints.ai_socktype = SOCK_STREAM;    /* TCP */

struct addrinfo *list;
int rc = getaddrinfo("example.com", "8080", &hints, &list);
if (rc != 0) {
    fprintf(stderr, "%s\n", gai_strerror(rc));   /* not perror */
    return -1;
}

for (struct addrinfo *ai = list; ai != NULL; ai = ai->ai_next) {
    int fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
    if (fd < 0) continue;
    if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) break;   /* success */
    close(fd);
}
freeaddrinfo(list);                  /* the list is allocated */

Two details: getaddrinfo returns its own error codes, so use gai_strerror rather than perror; and the result must be released with freeaddrinfo, which is week 19's ownership rule applied to a library.

Trying each address in turn matters. A host with both IPv6 and IPv4 addresses may be reachable on only one.

Byte order

Port numbers and addresses travel in network byte order — big-endian, as week 37 established. getaddrinfo handles it for the structures it fills, but any integer you put in a message needs converting:

uint32_t on_wire = htonl(value);
uint32_t local   = ntohl(on_wire);

3The server sequence

int listener = socket(AF_INET6, SOCK_STREAM, 0);

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

bind(listener, addr, addrlen);
listen(listener, 16);                        /* backlog */

for (;;) {
    int conn = accept(listener, NULL, NULL); /* a NEW descriptor */
    handle(conn);
    close(conn);
}

SO_REUSEADDR is not optional in practice. After a server exits, the kernel keeps the address in TIME_WAIT for up to two minutes, and bind fails with "Address already in use" — the error every beginner hits on their second run. The option says it is acceptable to rebind.

The backlog is the queue of completed connections waiting for accept. Beyond it, further connections are refused.

4Framing

TCP delivers a stream of bytes with no record boundaries whatsoever. Three send calls of 10 bytes may arrive as one recv of 30, or as 30 of 1. Nothing preserves your message structure — you must impose it.

SchemeHowTrade-off
DelimiterMessages end with \nSimple; the delimiter cannot appear in data
Length prefixA 4-byte big-endian length, then the payloadBinary safe; must bound the length
Fixed sizeEvery message the sameTrivial; wasteful and inflexible

Bound the declared length. A length-prefixed protocol that trusts the prefix hands an attacker malloc(0xFFFFFFFF) — or, after an integer overflow, a tiny buffer followed by a huge copy. Check the length against a maximum before allocating. This is week 36's allocation-overflow bug arriving over the network.

5Partial transfers and errors

send and recv may transfer less than requested — more often than local files, because the network is slow and buffers fill. The loop from week 39 is mandatory here:

static ssize_t send_all(int fd, const void *data, size_t n)
{
    const char *p = data;
    size_t sent = 0;
    while (sent < n) {
        ssize_t s = send(fd, p + sent, n - sent, MSG_NOSIGNAL);
        if (s < 0) {
            if (errno == EINTR) continue;
            return -1;
        }
        sent += (size_t)s;
    }
    return (ssize_t)sent;
}

recv returning 0 means the peer closed the connection in an orderly way — it is not an error and not a short read.

ConditionMeans
recv returns 0Peer closed; you are done
ECONNRESETPeer vanished abruptly
EPIPE + SIGPIPEYou wrote to a closed connection
ETIMEDOUTNo response within the limit
EINTRA signal arrived; retry

SIGPIPE deserves attention: by default, writing to a connection the peer has closed terminates your process. Pass MSG_NOSIGNAL to send, or ignore the signal once at startup — otherwise a client disconnecting at the wrong moment kills your server.

Timeouts

struct timeval tv = { .tv_sec = 5, .tv_usec = 0 };
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);

Without one, a peer that stops responding blocks you forever. A network program with no timeouts is not finished.

6Worked example: a line server and its client

/* echoserver.c — a TCP server, one forked child per connection */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <errno.h>
#include <unistd.h>
#include <signal.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <netdb.h>

#define BACKLOG      16
#define MAX_LINE   4096

static volatile sig_atomic_t stop = 0;

static void on_signal(int sig) { (void)sig; stop = 1; }

/* Reap children so they do not become zombies — week 39. */
static void on_child(int sig)
{
    (void)sig;
    int saved = errno;                      /* waitpid may change errno */
    while (waitpid(-1, NULL, WNOHANG) > 0) { }
    errno = saved;
}

static ssize_t send_all(int fd, const void *data, size_t n)
{
    const char *p = data;
    size_t sent = 0;
    while (sent < n) {
        ssize_t s = send(fd, p + sent, n - sent, MSG_NOSIGNAL);
        if (s < 0) {
            if (errno == EINTR) continue;
            return -1;
        }
        sent += (size_t)s;
    }
    return (ssize_t)sent;
}

/* Newline framing: accumulate until a '\n' appears. A stream has no
   message boundaries, so this loop is what creates them. */
static void serve_connection(int fd)
{
    char    buffer[MAX_LINE];
    size_t  used = 0;
    long    lines = 0;

    struct timeval tv = { .tv_sec = 30, .tv_usec = 0 };
    setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);

    const char *greeting = "ready\n";
    if (send_all(fd, greeting, strlen(greeting)) < 0) return;

    for (;;) {
        ssize_t r = recv(fd, buffer + used, sizeof buffer - used, 0);

        if (r == 0) {
            break;                          /* orderly close: not an error */
        }
        if (r < 0) {
            if (errno == EINTR) continue;
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                send_all(fd, "timeout\n", 8);
            }
            break;
        }
        used += (size_t)r;

        /* One recv may contain zero, one, or several complete lines. */
        char *start = buffer;
        char *nl;
        while ((nl = memchr(start, '\n', (size_t)(buffer + used - start)))) {
            size_t len = (size_t)(nl - start);
            lines++;

            if (len == 4 && memcmp(start, "quit", 4) == 0) {
                send_all(fd, "bye\n", 4);
                return;
            }

            char out[MAX_LINE + 32];
            int n = snprintf(out, sizeof out, "%ld: %.*s\n",
                             lines, (int)len, start);
            if (n > 0 && send_all(fd, out, (size_t)n) < 0) return;

            start = nl + 1;
        }

        /* Move the incomplete remainder to the front. */
        size_t leftover = (size_t)(buffer + used - start);
        memmove(buffer, start, leftover);
        used = leftover;

        if (used == sizeof buffer) {        /* a line longer than the buffer */
            send_all(fd, "line too long\n", 14);
            return;
        }
    }
}

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

    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = on_signal;
    sigaction(SIGINT,  &sa, NULL);
    sigaction(SIGTERM, &sa, NULL);

    memset(&sa, 0, sizeof sa);
    sa.sa_handler = on_child;
    sa.sa_flags   = SA_RESTART | SA_NOCLDSTOP;
    sigaction(SIGCHLD, &sa, NULL);

    signal(SIGPIPE, SIG_IGN);               /* a closed peer must not kill us */

    struct addrinfo hints;
    memset(&hints, 0, sizeof hints);
    hints.ai_family   = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags    = AI_PASSIVE;         /* for bind */

    struct addrinfo *list;
    int rc = getaddrinfo(NULL, port, &hints, &list);
    if (rc != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
        return EXIT_FAILURE;
    }

    int listener = -1;
    for (struct addrinfo *ai = list; ai != NULL; ai = ai->ai_next) {
        listener = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
        if (listener < 0) continue;

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

        if (bind(listener, ai->ai_addr, ai->ai_addrlen) == 0) break;
        close(listener);
        listener = -1;
    }
    freeaddrinfo(list);

    if (listener < 0) {
        perror("bind");
        return EXIT_FAILURE;
    }
    if (listen(listener, BACKLOG) < 0) {
        perror("listen");
        return EXIT_FAILURE;
    }

    printf("listening on port %s; Ctrl-C to stop\n", port);

    while (!stop) {
        int conn = accept(listener, NULL, NULL);
        if (conn < 0) {
            if (errno == EINTR) continue;   /* a signal, not a failure */
            perror("accept");
            break;
        }

        pid_t pid = fork();
        if (pid == 0) {
            close(listener);                /* the child does not need it */
            serve_connection(conn);
            close(conn);
            _exit(0);                       /* _exit in a child */
        }
        close(conn);                        /* the parent does not need it */
        if (pid < 0) perror("fork");
    }

    close(listener);
    puts("\nstopped");
    return EXIT_SUCCESS;
}
/* echoclient.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netdb.h>

static ssize_t send_all(int fd, const void *data, size_t n)
{
    const char *p = data;
    size_t sent = 0;
    while (sent < n) {
        ssize_t s = send(fd, p + sent, n - sent, MSG_NOSIGNAL);
        if (s < 0) { if (errno == EINTR) continue; return -1; }
        sent += (size_t)s;
    }
    return (ssize_t)sent;
}

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

    struct addrinfo hints;
    memset(&hints, 0, sizeof hints);
    hints.ai_family   = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;

    struct addrinfo *list;
    int rc = getaddrinfo(host, port, &hints, &list);
    if (rc != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
        return EXIT_FAILURE;
    }

    int fd = -1;
    for (struct addrinfo *ai = list; ai != NULL; ai = ai->ai_next) {
        fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
        if (fd < 0) continue;
        if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) break;
        close(fd);
        fd = -1;
    }
    freeaddrinfo(list);

    if (fd < 0) { perror("connect"); return EXIT_FAILURE; }

    struct timeval tv = { .tv_sec = 10, .tv_usec = 0 };
    setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);

    char line[1024];
    while (fgets(line, sizeof line, stdin) != NULL) {
        if (send_all(fd, line, strlen(line)) < 0) { perror("send"); break; }

        char reply[2048];
        ssize_t r = recv(fd, reply, sizeof reply - 1, 0);
        if (r < 0)  { perror("recv"); break; }
        if (r == 0) { puts("server closed the connection"); break; }
        reply[r] = '\0';
        fputs(reply, stdout);
    }

    close(fd);
    return EXIT_SUCCESS;
}
gcc -std=c17 -D_POSIX_C_SOURCE=200809L -Wall -Wextra -g -o echoserver echoserver.c
gcc -std=c17 -D_POSIX_C_SOURCE=200809L -Wall -Wextra -g -o echoclient echoclient.c

./echoserver 8080 &
printf 'hello\nworld\nquit\n' | ./echoclient localhost 8080
ready
1: hello
2: world
bye

Prove that a stream has no message boundaries

{ printf 'he'; sleep 0.3; printf 'llo\nwor'; sleep 0.3; printf 'ld\nquit\n'; } \
    | ./echoclient localhost 8080

The bytes arrive in three pieces that do not align with lines, and the server still reports exactly two numbered lines. That is the framing loop working: it accumulates, extracts every complete line, and keeps the remainder for the next recv. Remove the memmove and the partial "wor" is lost.

The other direction is equally real: send a thousand short lines quickly and several will arrive in one recv. The inner while over memchr is why that works.

Test the failure modes

./echoclient localhost 9999            # nothing listening
./echoclient no.such.host.invalid 8080 # resolution fails
nc localhost 8080                      # connect with netcat, type, Ctrl-C
head -c 1000000 /dev/zero | tr '\0' 'x' | ./echoclient localhost 8080
TestExpected
No listenerconnect: Connection refused
Bad hostnamegetaddrinfo: with a name-resolution message
Client killed mid-sessionServer child exits; parent keeps serving
A line longer than the bufferline too long, connection closed — not an overflow
Two clients at onceBoth served; one child each
Thirty seconds of silencetimeout, connection closed

The three defences that keep the server alive

SIGPIPE ignored. Remove signal(SIGPIPE, SIG_IGN), connect with nc, and kill the client while the server is replying. The server process dies — a client can terminate your service by disconnecting at the right moment.

SIGCHLD reaped. Remove the handler and run a hundred connections in a loop, then check ps -el | grep defunct. Every one is still there.

SO_REUSEADDR. Remove it, stop the server, and restart immediately: bind: Address already in use for up to two minutes.

Watch the bytes

sudo tcpdump -i lo -A 'port 8080'

Seeing your own protocol on the wire is worth doing once. It also makes concrete that nothing in TCP marks where one message ends — only your newline does.

7Common mistakes

MistakeWhat happensFix
Assuming one send equals one recvMessages split or mergedFrame explicitly and accumulate.
Ignoring a short sendSilent truncationLoop with send_all.
Treating recv == 0 as an errorMisreports an orderly closeZero means the peer finished.
No SO_REUSEADDRAddress already in use on restartSet it before bind.
Not ignoring SIGPIPEA disconnecting client kills the serverSIG_IGN or MSG_NOSIGNAL.
Not reaping childrenZombies accumulateSIGCHLD handler with WNOHANG.
Trusting a length prefixHuge allocation or overflowBound it before allocating.
No timeoutsA silent peer blocks you foreverSO_RCVTIMEO and SO_SNDTIMEO.
Sending integers without conversionWorks only between identical machineshtonl/ntohl.

8Check yourself

Why must a TCP protocol define its own message boundaries?

Because TCP delivers a byte stream, not records. The bytes from several send calls may arrive in one recv, and one send may arrive split across many. Nothing in the protocol marks where a message ends, so the application must impose a delimiter, a length prefix, or a fixed size — and accumulate until a complete message is present.

What does recv returning 0 mean, and how does it differ from a negative return?

Zero means the peer performed an orderly shutdown — there will be no more data, and this is normal. A negative return means an error, with the reason in errno: ECONNRESET if the peer vanished, EAGAIN on a timeout, EINTR if a signal arrived and you should retry. Treating zero as an error misreports a clean disconnection.

Why is SO_REUSEADDR effectively mandatory on a server?

Because after a connection closes the kernel keeps the address in TIME_WAIT for up to two minutes to catch stray packets, and bind on that address fails during the wait. Without the option, restarting a server means waiting; with it, the kernel permits the rebind.

How can a client disconnecting kill a server that never crashed?

Writing to a connection whose peer has closed raises SIGPIPE, whose default action is to terminate the process. A client that disconnects while the server is mid-reply therefore kills it. Ignore the signal at startup, or pass MSG_NOSIGNAL to send so the error arrives as EPIPE instead.

Why must a length-prefixed protocol bound the declared length?

Because the prefix comes from the peer, who may be hostile. An unbounded value becomes an enormous allocation — a denial of service — or, if the length is multiplied by an element size that overflows, a small buffer followed by a huge copy. Validate the length against a maximum before allocating anything.

9Where this leads

This server forks a process per connection, which is simple and costs a few hundred kilobytes each — fine for tens of clients, hopeless for tens of thousands. Week 47 rewrites it as a single-threaded event loop using epoll and measures the difference. Week 41 first covers the other approach: threads sharing one address space.