Networking in C · intermediate · ~10 min

Sockets introduction

- Explain what a socket is and why it behaves like a file descriptor - Choose the correct **address family** (`AF_INET`, `AF_INET6`, `AF_UNIX`) and **type** (`SOCK_STREAM`, `SOCK_DGRAM`) for a task - Create a socket with `socket()` and fill in a `struct sockaddr_in` correctly - Convert port numbers and addresses to **network byte order** with `htons`/`htonl` - Trace the server call sequence `socket → bind → listen → accept` and the client sequence `socket → connect` - Close every socket and check every return value so no descriptor or connection leaks

Overview

A socket is one endpoint of a two-way communication link between two programs — often on two different machines. Think of a network connection as a pipe stretched across the network: a socket is the mouth of that pipe on your side. You write bytes into it and they come out the other end; the other program writes bytes back and they come out of yours.

The most important idea in this lesson connects directly to the prerequisite File descriptors: on Unix, a socket is a file descriptor. When you call socket() you get back a small non-negative integer, exactly like open() gives you for a file. You can read() and write() it, you must close() it, and it counts against your process's descriptor limit. Everything you already know about descriptors — that they are just indexes into a per-process table, that leaking them is a real bug, that -1 means failure — applies here unchanged.

What makes sockets special is that one small API covers every kind of network communication. The same handful of calls — socket, bind, listen, accept, connect, read, write, close — work for TCP, UDP, and local same-machine channels. You do not learn a new API for each protocol; you pass different constants to socket() and the kernel does the rest. This design, the Berkeley (BSD) sockets API, has been the standard since the early 1980s and is what Python, Go, Rust, and Java all wrap underneath.

In plain language: a socket is a phone line. socket() buys the phone, bind() assigns it a number, listen()/accept() waits for calls, and connect() dials out. In this lesson you set up the phone; in the next lessons you make and answer calls.

Why it matters

Sockets sit underneath essentially every networked program you use:

  • Web servers and browsers — every HTTP request rides on a TCP socket.
  • Database clients — PostgreSQL, MySQL, and Redis all speak their wire protocols over sockets.
  • Chat and messaging apps — real-time delivery is sockets end to end.
  • IoT and embedded devices — a thermostat reporting to the cloud opens a socket.
  • Operating-system services — DNS resolvers, time sync, and package managers all use them.

Because higher-level libraries wrap this exact API, understanding C sockets means you understand what every networking framework is actually doing. When a Python requests call hangs, or a Go server reports "address already in use", the cause lives in the socket layer you learn here. It is also the foundation for the security side of networking: to reason about what a service exposes, who can connect, and how data crosses trust boundaries, you first need to know how the connection is built.

Core concepts

1. A socket is a file descriptor

Definition. A socket is a kernel object for network I/O, referenced from your program by a file descriptor — a small non-negative integer.

Plain-language explanation. When you open a file you get a descriptor; when you create a socket you get one too. The number is meaningless on its own; it is an index into a table the kernel keeps for your process. Everything downstream — reading, writing, closing — uses that number.

How it works internally. The kernel keeps a per-process descriptor table. Entry N points at a kernel structure describing the open resource. For a file that structure tracks a position in the file; for a socket it tracks connection state, buffers, and the protocol. Your program never sees the structure — only the integer handle.

Process descriptor table
 +-----+---------------------------+
 | fd  | kernel object             |
 +-----+---------------------------+
 |  0  | stdin  (terminal)         |
 |  1  | stdout (terminal)         |
 |  2  | stderr (terminal)         |
 |  3  | SOCKET  --> TCP endpoint   |  <- socket() returned 3
 +-----+---------------------------+
                     |
                     v
            [ send buffer ][ recv buffer ]
            [ protocol state: TCP, peer, port ]

When to use / not use. Treat a socket like any descriptor for read/write/close. But do not assume file conveniences carry over: sockets have no meaningful "position", lseek fails on them, and a single read may return fewer bytes than the peer sent.

Pitfall. Forgetting that socket() returns -1 on failure. If you skip the check and use -1 as a descriptor, every later call fails in confusing ways.

Knowledge check: In your own words, why can you call close() on both a file and a socket even though one is on disk and one is a network connection?

2. Address family — what kind of address

Definition. The address family selects the naming scheme for endpoints.

Family Meaning Address struct
AF_INET IPv4 (e.g. 127.0.0.1) struct sockaddr_in
AF_INET6 IPv6 (e.g. ::1) struct sockaddr_in6
AF_UNIX Local, same-machine, by path struct sockaddr_un

How it works. The family decides which address structure you fill in and how the kernel routes the traffic. AF_INET uses a 32-bit IPv4 address plus a 16-bit port. AF_UNIX uses a filesystem path and never touches the network card at all — it is the fastest way for two processes on one machine to talk.

When to use / not use. Use AF_INET/AF_INET6 to talk across a network; use AF_UNIX for local inter-process channels (a database and its client on the same host). Do not mix a family with the wrong address struct — that is a classic beginner crash.

Pitfall. Setting sin_family to AF_INET but casting a sockaddr_un into bind. The sizes and layouts differ; the kernel rejects it or misreads memory.

3. Type — the style of communication

Definition. The socket type selects the delivery guarantees.

Type Protocol Guarantees Analogy
SOCK_STREAM TCP Reliable, ordered, connection-based A phone call
SOCK_DGRAM UDP Best-effort, unordered, message Postcards

How it works. SOCK_STREAM (TCP) gives you a byte stream: the kernel handles acknowledgements, retransmission, and ordering, so what you write eventually arrives, in order — but with no message boundaries. SOCK_DGRAM (UDP) gives you discrete datagrams: each sendto is one message that may arrive, arrive out of order, or vanish, but boundaries are preserved.

SOCK_STREAM (TCP): boundaries dissolve
  write("HEL"); write("LO");  --->  peer read() might get "HELLO" or "HE"+"LLO"

SOCK_DGRAM (UDP): boundaries kept, delivery not guaranteed
  sendto("HELLO");            --->  peer gets "HELLO" as one datagram, or nothing

When to use / not use. Use SOCK_STREAM when you need every byte in order (HTTP, databases, file transfer). Use SOCK_DGRAM when speed beats reliability and you can tolerate loss (live video, games, DNS queries). Do not assume one TCP read equals one logical message — you must frame messages yourself.

Pitfall. Expecting SOCK_STREAM to preserve your write() boundaries. It does not; treat incoming data as an unbounded stream and re-assemble.

Knowledge check (find-the-bug): A learner writes a 100-byte request with one write, then does one read expecting 100 bytes back and gets 40. Nothing is broken — what did they forget about SOCK_STREAM?

4. Network byte order (endianness)

Definition. The network transmits multi-byte integers big-endian first ("network byte order"), regardless of how your CPU stores them internally ("host byte order").

How it works. A port like 8080 is a 16-bit number = 0x1F90. A big-endian machine stores it as bytes 1F 90; a little-endian machine (x86, most ARM) stores it 90 1F. If you put raw host bytes on the wire, the peer reads the wrong number. The conversion helpers fix this:

Helper Direction Width
htons host → network 16-bit
htonl host → network 32-bit
ntohs network → host 16-bit
ntohl network → host 32-bit

On a big-endian CPU these are no-ops; on little-endian they swap bytes. Because you cannot know at compile time which CPU runs your code, always wrap ports and addresses in them.

When to use / not use. Wrap the port (sin_port) with htons and numeric addresses with htonl. Do not wrap payload data you send with write — byte order helpers are only for the address fields and any integers in your own protocol.

Pitfall. Writing .sin_port = 8080 directly. It compiles, but binds to port 36895 (the byte-swapped value) on a little-endian machine.

Knowledge check (predict-the-output): On an x86 laptop, htons(1) returns what 16-bit value in hex, and why?

5. The call sequence: server vs client

Definition. Server and client use the same primitives in different orders.

SERVER                         CLIENT
  socket()   create endpoint     socket()   create endpoint
  bind()     claim host:port
  listen()   mark passive
  accept()   wait for a client <--- connect()  reach the server
  read()/write() exchange data <--> write()/read()
  close()                          close()

How it works. bind reserves an address so clients can find the server. listen flips the socket into passive mode with a backlog queue. accept blocks until a client arrives and returns a new descriptor for that one conversation — the original listening socket keeps waiting for the next client. On the client, connect performs the TCP handshake to the server's address.

When to use / not use. Only servers call bind/listen/accept. A pure client never binds; it just connects. Do not confuse the listening socket with the connected socket accept returns — they are two different descriptors with different jobs.

Pitfall. Reading from the listening socket instead of the descriptor returned by accept. The listener never carries client data.

6. Localhost during development

Every lab in this course binds to 127.0.0.1 (the loopback address, INADDR_LOOPBACK). Traffic to it never leaves your machine, so nothing is exposed to the outside world while you learn. Binding to INADDR_ANY (0.0.0.0) would accept connections from the whole network — powerful, but unsafe for practice code.

Syntax notes

#include <sys/socket.h>   // socket, bind, listen, accept
#include <netinet/in.h>   // struct sockaddr_in, htons, htonl, INADDR_LOOPBACK
#include <arpa/inet.h>    // inet_ntop / inet_pton (address <-> text)

// 1. Create a TCP/IPv4 socket. 0 = default protocol for the type (TCP).
int s = socket(AF_INET, SOCK_STREAM, 0);
if (s < 0) { /* handle error: socket returned -1 */ }

// 2. Describe the address to bind to.
struct sockaddr_in addr = {0};              // zero every field first
addr.sin_family      = AF_INET;             // IPv4
addr.sin_port        = htons(8080);         // port in NETWORK byte order
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 127.0.0.1

// 3. bind takes a generic sockaddr*, so cast the concrete struct.
if (bind(s, (struct sockaddr *)&addr, sizeof addr) < 0) { /* handle */ }

// 4. Become a passive listener with a backlog of 16 pending connections.
if (listen(s, 16) < 0) { /* handle */ }

Key points: socket() returns a descriptor or -1; bind, listen, accept, connect all return 0/descriptor on success and -1 on failure with errno set. The {0} initializer is important — leftover garbage in unset fields of sockaddr_in causes intermittent bind failures.

Lesson

A socket is a file descriptor for network I/O. The BSD sockets API has been the C standard for this since the early 1980s.

The core calls each do one job:

  • socket() — create a socket
  • bind() — tie it to an address
  • listen() / accept() — wait for incoming connections
  • connect() — reach out to another machine

Two socket types cover most needs:

  • SOCK_STREAM is TCP — reliable, ordered bytes.
  • SOCK_DGRAM is UDP — packets, which may be lost or reordered.

Code examples

// tcp_echo_once.c — a minimal, safe TCP echo server bound to localhost. // It accepts ONE client, echoes back whatever the client sends, then exits. // Build: cc -std=c11 -Wall -Wextra -o tcp_echo_once tcp_echo_once.c // Test : in another terminal run: nc 127.0.0.1 8080 #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> // read, write, close #include <sys/socket.h> // socket, bind, listen, accept, setsockopt #include <netinet/in.h> // sockaddr_in, htons, htonl, INADDR_LOOPBACK #include <arpa/inet.h> // inet_ntop

#define PORT 8080 #define BACKLOG 16

int main(void) { // 1. Create a TCP/IPv4 socket. int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); return 1; }

// Allow immediate reuse of the port after the program exits,
// avoiding "Address already in use" during rapid restarts.
int yes = 1;
if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes) < 0) {
    perror("setsockopt");
    close(listen_fd);
    return 1;
}

// 2. Describe the local address: 127.0.0.1:8080.
struct sockaddr_in addr = {0};                 // zero all fields
addr.sin_family      = AF_INET;
addr.sin_port        = htons(PORT);            // network byte order
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // loopback only, safe

// 3. Claim the address.
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof addr) < 0) {
    perror("bind");
    close(listen_fd);
    return 1;
}

// 4. Switch to passive/listening mode.
if (listen(listen_fd, BACKLOG) < 0) {
    perror("listen");
    close(listen_fd);
    return 1;
}
printf("Listening on 127.0.0.1:%d ...\n", PORT);

// 5. Wait for and accept ONE client. accept() returns a NEW descriptor
//    dedicated to this one connection; listen_fd keeps listening.
struct sockaddr_in peer = {0};
socklen_t peer_len = sizeof peer;
int conn_fd = accept(listen_fd, (struct sockaddr *)&peer, &peer_len);
if (conn_fd < 0) {
    perror("accept");
    close(listen_fd);
    return 1;
}

char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof ip); // binary -> text
printf("Client connected from %s:%d\n", ip, ntohs(peer.sin_port));

// 6. Echo loop: read a chunk, write exactly what we read back.
char buf[1024];
ssize_t n;
while ((n = read(conn_fd, buf, sizeof buf)) > 0) {
    // recv/read data is NOT null-terminated; use the (buf, n) length.
    ssize_t off = 0;
    while (off < n) {                 // write may be partial; loop it
        ssize_t w = write(conn_fd, buf + off, (size_t)(n - off));
        if (w < 0) { perror("write"); break; }
        off += w;
    }
}
if (n < 0) perror("read");           // n == 0 means client closed cleanly

// 7. Clean up BOTH descriptors.
close(conn_fd);
close(listen_fd);
printf("Connection closed.\n");
return 0;

}


**What it does.** The program opens a TCP socket, binds it to `127.0.0.1:8080`, listens, and waits for a single client. When you connect with `nc 127.0.0.1 8080` and type a line, the server reads your bytes and writes them straight back — an echo. Typing `hello` shows `hello` again in the `nc` window. When you close `nc` (Ctrl-D or Ctrl-C), `read` returns `0`, the loop ends, both descriptors close, and the server prints `Connection closed.` and exits.

**Expected output (server terminal):**
```text
Listening on 127.0.0.1:8080 ...
Client connected from 127.0.0.1:54321
Connection closed.

(The client's source port 54321 is chosen by the OS and will differ each run.)

Edge cases to note. If port 8080 is already taken, bind fails with "Address already in use" (mitigated by SO_REUSEADDR). If the client sends more than 1024 bytes at once, the loop simply handles it in multiple iterations — the buffer size does not limit total data. A partial write (rare on loopback, common on real networks) is handled by the inner off loop.

Line by line

Step 1 — socket(AF_INET, SOCK_STREAM, 0). Asks the kernel for an IPv4 TCP endpoint. It returns a descriptor (say 3) stored in listen_fd, or -1. The < 0 check catches failure before anything else runs.

Step 2 — setsockopt(..., SO_REUSEADDR, ...). Sets a flag so the port can be re-bound immediately after the program exits, instead of sitting in the TCP TIME_WAIT state. Without it, restarting quickly gives "Address already in use".

Step 3 — filling struct sockaddr_in. = {0} zeroes every byte, including padding, so no garbage leaks into the kernel. sin_family = AF_INET says IPv4. sin_port = htons(8080) stores the port in network byte order. sin_addr.s_addr = htonl(INADDR_LOOPBACK) sets the address to 127.0.0.1.

Step 4 — bind. Hands the kernel the address, reserving 127.0.0.1:8080 for this socket. The cast (struct sockaddr *)&addr is required because bind takes the generic base type but reads the concrete IPv4 struct.

Step 5 — listen(listen_fd, 16). Marks the socket passive and creates a queue holding up to 16 connections that have handshaked but not yet been accepted.

Step 6 — accept. Blocks (the program pauses) until a client connects. It returns a brand-new descriptor conn_fd for that single conversation and fills peer with the client's address. inet_ntop turns the binary address into readable text; ntohs(peer.sin_port) converts the client's port back to host order for printing.

Step 7 — the echo loop. Here is a trace for a client that sends hi (2 bytes) then disconnects:

Iteration read returns n buf contents Inner write loop Result
1 2 h,i writes 2 bytes client sees hi
2 0 (skipped) loop exits (peer closed)

Each outer iteration reads whatever bytes are available (not necessarily a whole line). The inner off loop guarantees the full chunk is written even if write accepts only part of it. read returning 0 is the signal that the peer closed the connection — the normal end.

Step 8 — cleanup. close(conn_fd) releases the conversation; close(listen_fd) releases the listener. Both matter: leaking either one leaks a descriptor.

Common mistakes

Mistake 1 — Forgetting htons on the port.

addr.sin_port = 8080;            // WRONG on little-endian machines

Why it's wrong: the raw 16 bits are stored in host order, so the kernel reads a byte-swapped port (808036895). Your server "works" but listens on the wrong port and clients can't reach it.

addr.sin_port = htons(8080);     // CORRECT

Recognise it: connect fails / times out even though bind succeeded; ss -tlnp shows a surprising port number.

Mistake 2 — Reading from the listening socket.

read(listen_fd, buf, sizeof buf);   // WRONG — listener carries no data

The listening socket only produces new connections via accept. Read the descriptor accept returns instead:

int conn_fd = accept(listen_fd, NULL, NULL);
read(conn_fd, buf, sizeof buf);     // CORRECT

Recognise it: read blocks forever or returns errors; no data ever arrives.

Mistake 3 — Treating received bytes as a C string.

ssize_t n = read(conn_fd, buf, sizeof buf);
printf("%s\n", buf);                // WRONG — buf is not null-terminated

The network gives you (bytes, length), not a terminated string, so printf("%s") reads past the data. Either use the length or add a terminator you have room for:

ssize_t n = read(conn_fd, buf, sizeof buf - 1);
if (n > 0) { buf[n] = '\0'; printf("%s\n", buf); }   // CORRECT

Recognise it: garbage or extra characters printed; occasional crashes under ASan.

Mistake 4 — Ignoring return values.

bind(s, (struct sockaddr *)&addr, sizeof addr);   // WRONG — unchecked
listen(s, 16);

If bind fails, listen and accept fail too, and you chase the symptom instead of the cause. Always check for < 0 and print errno with perror.

Mistake 5 — Leaking the connected socket. Closing only listen_fd at the end but forgetting conn_fd (or vice-versa) leaks a descriptor per client. In a long-running server this eventually hits the descriptor limit and every new accept fails with EMFILE.

Debugging tips

Compiler errors.

  • storage size of 'addr' isn't known → you forgot #include <netinet/in.h>.
  • implicit declaration of function 'socket' → missing #include <sys/socket.h>.
  • Passing struct sockaddr_in * where struct sockaddr * is expected → add the cast (struct sockaddr *)&addr.

Runtime errors (check errno / perror).

  • Address already in use (EADDRINUSE) → a previous run holds the port in TIME_WAIT; set SO_REUSEADDR, or wait ~60s, or pick another port.
  • Permission denied (EACCES) → you tried to bind a well-known port (1–1023) without privilege. Use a port ≥ 1024.
  • accept hangs forever → nothing has connected yet (normal), or you're reading the listening socket by mistake.

Logic errors.

  • Data seems truncated → remember read on SOCK_STREAM can return fewer bytes than sent; loop until you have what you need.
  • Wrong port on the wire → you skipped htons.

Concrete debugging steps.

  1. ss -tlnp (or netstat -tlnp) — confirm your process is actually listening on the expected address and port.
  2. nc 127.0.0.1 8080 — a one-line client to test the handshake and echo without writing client code.
  3. Compile with -Wall -Wextra and run under valgrind or -fsanitize=address to catch buffer misuse and descriptor leaks.
  4. Add perror("<callname>") after every socket call so a failure names itself.

Questions to ask when it doesn't work.

  • Did every socket call return >= 0? Which one first returned -1?
  • Is the port wrapped in htons? Is the address INADDR_LOOPBACK?
  • Am I reading the descriptor from accept, not the listener?
  • Is another process already on this port (ss -tlnp)?

Memory safety

Sockets are descriptors, so classic descriptor and buffer rules dominate.

Descriptor lifetime / leaks. Close every socket on every path, including error paths — not just the happy path. A server that leaks one descriptor per client eventually exhausts its limit and cannot accept anymore (EMFILE). In the example, both close(conn_fd) and close(listen_fd) run, and each early-error branch closes what it already opened.

Received data is not a string. read/recv fill a buffer with raw bytes and return a length; there is no trailing '\0'. Never pass that buffer to printf("%s"), strlen, strcpy, or strcmp without either using the returned length or writing your own terminator within the buffer's bounds. Reading sizeof buf - 1 and setting buf[n] = '\0' is the safe pattern.

Bounds and the byte count. Always cap read/write at your buffer size (sizeof buf), and always respect the returned count n. Do not assume one read returns a whole message, and do not write more than n bytes back.

Initialisation. Zero the whole struct sockaddr_in with = {0} before filling it. Uninitialised padding or unset fields passed to bind/connect cause undefined behaviour and flaky failures.

Integer / port validation. A TCP/UDP port is a 16-bit value; valid usable ports are 1..65535. Validate any port that comes from user input before calling htons (this is exactly what the related exercises sockets-intro-ex1 and sockets-intro-ex2 drill). Feeding an out-of-range or negative port through wraps silently.

Defensive habit for later (network input is untrusted). Even though this lesson binds to loopback only, get in the habit now: treat every byte from the network as attacker-controlled. Validate lengths before indexing, never let received data pick a buffer size, and bind to 127.0.0.1 — not INADDR_ANY — while developing so practice servers are never reachable from outside your machine.

Real-world uses

Concrete use case. Every web server — nginx, Apache, a Node.js app, a Go microservice — creates a SOCK_STREAM socket, binds it to a port (usually 80 or 443), listens, and accepts a fresh connected socket per browser request. The exact socket → bind → listen → accept sequence in this lesson is the literal core of an HTTP server's main loop. Databases (PostgreSQL on 5432, MySQL on 3306) do the same; their clients connect and exchange a wire protocol over the stream.

Professional best-practice habits.

  • Naming/readability: name descriptors for their role (listen_fd, conn_fd), not s/s2, so the two-socket distinction is obvious.
  • Validation: check the return of every socket call; validate ports and lengths from any external source.
  • Error handling: report failures with perror/strerror(errno) so logs name the failing syscall.
  • Cleanup: close both the listener and each connection; free buffers; on a real server, reap or handle each connection then continue the loop.
  • Safety default: bind to loopback in development; only expose 0.0.0.0 deliberately.

Beginner vs advanced.

Aspect Beginner (this lesson) Advanced (later)
Connections Handle one client, then exit Loop accept forever, one thread/process/event per client
Concurrency Blocking calls, single-threaded epoll/kqueue, non-blocking sockets, thread pools
Addressing Hard-coded AF_INET + sockaddr_in getaddrinfo for IPv4/IPv6-agnostic, name resolution
Robustness Basic perror checks Timeouts, SIGPIPE handling, graceful shutdown, TLS

Practice tasks

Beginner 1 — Create and inspect a socket. Objective: confirm socket() returns a real descriptor. Requirements: call socket(AF_INET, SOCK_STREAM, 0), print the returned integer, check for -1 with perror, and close it. Expected output: something like socket fd = 3. Hint: descriptors are usually the smallest unused non-negative integer. Concepts: socket-as-descriptor, error checking, cleanup.

Beginner 2 — Fill an address struct correctly. Objective: build a struct sockaddr_in for 127.0.0.1:9000. Requirements: zero the struct, set sin_family, wrap the port with htons and the address with htonl(INADDR_LOOPBACK). Print the port back using ntohs(addr.sin_port) to prove the round-trip. Expected output: port = 9000. Constraints: no hard-coded byte values. Hint: if the printed port is not 9000, you skipped a conversion helper. Concepts: address family, network byte order.

Intermediate 1 — Bind and listen with error reporting. Objective: turn the address struct into a listening socket. Requirements: create the socket, set SO_REUSEADDR, bind to 127.0.0.1:9000, listen with a backlog of 8, and print Listening.... Check every call and perror on failure. Verify with ss -tlnp in another terminal that the port is open, then close cleanly. Constraints: must not crash if the port is already in use — report and exit. Hint: the cast (struct sockaddr *)&addr is required in bind. Concepts: bind, listen, SO_REUSEADDR, error handling.

Intermediate 2 — Accept one client and report its address. Objective: extend Intermediate 1 to accept a connection. Requirements: accept a client, use inet_ntop + ntohs to print the client IP and port, then close both descriptors. Test with nc 127.0.0.1 9000. Expected output: Client connected from 127.0.0.1:<random port>. Hint: accept returns a new descriptor — do not overwrite listen_fd. Concepts: accept, connected vs listening socket, address text conversion.

Challenge — A length-safe echo server. Objective: build a one-client echo server that never mishandles buffers. Requirements: read into a fixed buffer, echo back exactly n bytes using a write loop that handles partial writes, and loop until read returns 0. Never treat the buffer as a null-terminated string. Add a rule: if a single line begins with the four bytes QUIT, stop echoing and shut down. Handle client disconnect (read == 0) and errors (read < 0) distinctly. Constraints: no strlen/%s on network data; every path closes both descriptors. Hint: compare the first bytes with memcmp(buf, "QUIT", 4) only when n >= 4. Concepts: stream framing, partial writes, buffer bounds, descriptor cleanup.

Summary

  • A socket is a file descriptor for network I/O: read, write, and close it like any descriptor, and check for -1.
  • One small BSD sockets API covers everything; you pick behaviour with the address family (AF_INET/AF_INET6/AF_UNIX) and type (SOCK_STREAM = reliable TCP stream, SOCK_DGRAM = best-effort UDP datagrams).
  • Servers run socket → bind → listen → accept; clients run socket → connect. accept returns a new descriptor for each connection — never read the listener.
  • Ports and addresses go on the wire in network byte order: wrap ports with htons, addresses with htonl. Forgetting this is the most common beginner bug.
  • Received bytes are not null-terminated and one read is not one message — respect the returned length and loop.
  • Bind to 127.0.0.1 while learning, validate ports (1..65535), check every return value, and close every socket on every path. From here you can build any client (tcp-client, the next lesson) or server.

Practice with these exercises