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.
- Explain the client and server call sequences and what each step does.
- Resolve a host and port with
getaddrinforather 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
accept returns a new descriptor for the connection; the listening socket stays open for the next one.
TCP (SOCK_STREAM) | UDP (SOCK_DGRAM) | |
|---|---|---|
| Connection | Established first | None |
| Delivery | Reliable, ordered | Best effort; may be lost, duplicated, reordered |
| Boundaries | None — a byte stream | Preserved per datagram |
| Used for | HTTP, SSH, databases | DNS, 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.
| Scheme | How | Trade-off |
|---|---|---|
| Delimiter | Messages end with \n | Simple; the delimiter cannot appear in data |
| Length prefix | A 4-byte big-endian length, then the payload | Binary safe; must bound the length |
| Fixed size | Every message the same | Trivial; 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.
| Condition | Means |
|---|---|
recv returns 0 | Peer closed; you are done |
ECONNRESET | Peer vanished abruptly |
EPIPE + SIGPIPE | You wrote to a closed connection |
ETIMEDOUT | No response within the limit |
EINTR | A 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 8080ready
1: hello
2: world
byeProve that a stream has no message boundaries
{ printf 'he'; sleep 0.3; printf 'llo\nwor'; sleep 0.3; printf 'ld\nquit\n'; } \
| ./echoclient localhost 8080The 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| Test | Expected |
|---|---|
| No listener | connect: Connection refused |
| Bad hostname | getaddrinfo: with a name-resolution message |
| Client killed mid-session | Server child exits; parent keeps serving |
| A line longer than the buffer | line too long, connection closed — not an overflow |
| Two clients at once | Both served; one child each |
| Thirty seconds of silence | timeout, 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
| Mistake | What happens | Fix |
|---|---|---|
Assuming one send equals one recv | Messages split or merged | Frame explicitly and accumulate. |
Ignoring a short send | Silent truncation | Loop with send_all. |
Treating recv == 0 as an error | Misreports an orderly close | Zero means the peer finished. |
No SO_REUSEADDR | Address already in use on restart | Set it before bind. |
Not ignoring SIGPIPE | A disconnecting client kills the server | SIG_IGN or MSG_NOSIGNAL. |
| Not reaping children | Zombies accumulate | SIGCHLD handler with WNOHANG. |
| Trusting a length prefix | Huge allocation or overflow | Bound it before allocating. |
| No timeouts | A silent peer blocks you forever | SO_RCVTIMEO and SO_SNDTIMEO. |
| Sending integers without conversion | Works only between identical machines | htonl/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.