JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Writing a TCP Server

The client guide gave you the dialling side: one connect() and you were talking. The server side is the patient, stranger half — it picks an address, announces that it is open for business, and then waits for callers it has never met. This guide walks the server life cycle one syscall at a time, builds a complete echo server that checks every error, and is honest about the trap that catches everyone: one server, many clients.

Client and server are not symmetric

In the previous guide your client was the one who reached out: it called socket() to make a TCP socket, then a single connect(), and the kernel ran the three-way handshake for you. The whole thing read almost like opening a file. The server is genuinely different work, and it helps to say so up front. A client knows exactly who it wants to talk to — one address, one port — and initiates. A server does not know who will call, or when, or how many. Its job is to be reachable: to claim a fixed address, announce it is listening, and then sit and wait for connections to arrive.

Because of that, the server side has more steps than the client side, and they fall into a fixed order. A server calls socket(), bind(), listen(), and accept(), in that sequence, before it ever moves a single byte. Each one does exactly one job: socket() makes the endpoint, bind() nails it to a known address and port, listen() flips it into a state where the kernel will accept incoming handshakes on your behalf, and accept() hands you one finished connection at a time. Skip or reorder any of these and the kernel will refuse you with a clear errno — which, since you check every return value, you will actually see.

Four calls, walked one at a time

  1. socket(AF_INET, SOCK_STREAM, 0) creates an unbound, unconnected TCP endpoint and returns a file descriptor — a small int, exactly like the ones from the file-I/O rung. It is not yet attached to any address.
  2. bind() attaches the socket to a local address and port you fill into a struct sockaddr_in — for example port 8080 on every interface (INADDR_ANY). This is where you claim the port; if another program already holds it, bind() fails with EADDRINUSE.
  3. listen(fd, backlog) tells the kernel this socket is now passive: stop trying to connect, start accepting incoming handshakes. The backlog sizes the queue of connections that have finished the handshake but which you have not accept()ed yet.
  4. accept(fd, ...) blocks until a client connects, then returns a NEW file descriptor for that one connection. You read() and write() (or recv() and send()) on the new fd; the original listening fd stays open to accept the next client.

Two details in that list deserve a closer look. First, the address you give bind() must be filled in network byte order, the topic of the second guide in this rung. The port number goes through htons() and INADDR_ANY through htonl() — skip those and your server will quietly bind to a scrambled port number on a little-endian machine and you will spend an hour wondering why nobody can reach it. Second, the backlog is not a limit on how many clients you can ever serve; it is only the depth of the short queue of not-yet-accepted connections. A value of 128 is conventional and almost always fine.

A complete, error-checked echo server

Here is the whole life cycle in one small program: an echo server that accepts one client, reads whatever the client sends, writes the same bytes straight back, and loops to the next client. It is the server half of the echo client and server pair — the "hello world" of socket programming. Read it once for shape, then notice that every single call that can fail is checked. setsockopt() with SO_REUSEADDR is the small kindness that lets you restart the server immediately instead of waiting out the kernel's TIME_WAIT period on the old port.

#include <sys/socket.h>
#include <netinet/in.h>   /* sockaddr_in, htons, htonl */
#include <arpa/inet.h>
#include <unistd.h>       /* read, write, close */
#include <string.h>
#include <stdio.h>
#include <stdlib.h>

#define PORT    8080
#define BACKLOG 128

int main(void) {
    /* 1. make the listening socket */
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if (lfd == -1) { perror("socket"); return 1; }

    /* allow immediate restart on the same port */
    int one = 1;
    if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR,
                   &one, sizeof one) == -1) {
        perror("setsockopt"); return 1;
    }

    /* 2. fill the address in NETWORK byte order, then bind */
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof addr);
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);  /* every interface */
    addr.sin_port        = htons(PORT);        /* host -> network */
    if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) == -1) {
        perror("bind"); return 1;
    }

    /* 3. switch the socket to passive / listening */
    if (listen(lfd, BACKLOG) == -1) { perror("listen"); return 1; }
    printf("listening on port %d\n", PORT);

    /* 4. serve clients one at a time */
    for (;;) {
        struct sockaddr_in peer;
        socklen_t plen = sizeof peer;
        int cfd = accept(lfd, (struct sockaddr *)&peer, &plen);
        if (cfd == -1) { perror("accept"); continue; }

        char buf[4096];
        ssize_t n;
        /* echo until the client closes (read returns 0 = EOF) */
        while ((n = read(cfd, buf, sizeof buf)) > 0) {
            ssize_t off = 0;
            while (off < n) {          /* handle partial writes */
                ssize_t w = write(cfd, buf + off, n - off);
                if (w == -1) { perror("write"); break; }
                off += w;
            }
        }
        if (n == -1) perror("read");
        close(cfd);                    /* done with THIS client */
    }
    /* not reached; lfd would be closed on a real shutdown path */
}

/* build:  gcc -O2 -Wall server.c  &&  ./a.out */
The two nested loops matter: the outer for(;;) accepts clients forever, the inner while drains one client until read() returns 0, which is the EOF that says the peer closed. The off/w loop guards against a short write — write() may move fewer bytes than you asked, and ignoring that silently drops data.

EOF, partial writes, and the bytes-not-messages truth

Three honest details in that loop separate a toy from a correct server. First, read() returning 0 means end of file — the client has closed its end, and that is your signal to close cfd and move on. A return of 0 is not an error and not an empty read to retry; it is a clean goodbye. Confuse 0 with -1 (the real error) and you will either spin forever or quit on a healthy connection. Second, read() and write() on a socket can move fewer bytes than you asked, which is why the inner off/w loop exists; a write that returns 30 when you offered 100 has not failed, it has just done part of the job and expects you to call again with the rest.

Third, and most easily missed: TCP gives you a stream of bytes, with no message boundaries. If a client calls write() three times — "hi", "there", "!" — your server's read() may deliver them as one chunk "hithere!", or split "hit" then "here!", or any other carving. The kernel makes no promise that one write on the sender becomes one read on the receiver. An echo server does not care, because it just bounces back whatever bytes arrive. But a real protocol does care, and the burden of finding where one message ends and the next begins — a length prefix, a delimiter like a newline — falls entirely on you. This is the single most surprising fact about TCP for people who expected it to deliver tidy messages.

The honest limit: one client at a time

Now the gap you must see plainly, because it is the whole reason real servers are more complicated. The loop above serves clients strictly one at a time. accept() returns client A, the inner loop echoes A until A closes, and only then does the outer loop call accept() again for client B. While A is connected and idle, B is stuck waiting in the listen backlog, and a third client may be refused outright. For a one-machine learning exercise this is fine. For anything with two simultaneous users, it is a brick wall — and it is not a bug in your code, it is a fundamental consequence of doing everything on one blocking thread of control.

There are three classic ways out, and you have met the machinery for all three in earlier rungs. The oldest is to fork() a child process per accepted client — the parent goes straight back to accept() while the child handles that one connection and exits; you already know fork() and reaping from the process rung. The second is a thread per client, lighter than a process and sharing one address space, using the pthreads you met in the concurrency rung. The third, and the one that scales furthest, is to stay on one thread but stop blocking: ask the kernel "which of my many sockets is ready?" and service only those.

That third path is exactly where the final guide of this rung goes. Right now every read() and accept() in your server is a blocking call: it parks the whole process until something happens. The last guide, UDP and Non-Blocking Sockets, shows the alternative — sockets that return immediately instead of parking, plus the io-multiplexing tools (select, poll, epoll) that let a single thread watch hundreds of connections at once. It also steps over to UDP, the connectionless cousin of TCP, where there is no accept() and no stream at all. You now have the patient half of the conversation working end to end; the next guide teaches it to listen to a whole room.