Networking · lesson 05

TCP: the handshake

TCP gives you a connection: bytes arrive in order, nothing is lost, nothing is duplicated. Before any of that can happen, two kernels have to agree that a connection exists. That agreement is three packets, and it produces a new file descriptor.

in progress
14 min interactive

accept returned a different number

You create a socket. It is fd 3. You bind it, you listen on it, you call accept. A client connects. accept returns 4. You now have two sockets, and if you read from fd 3 you get an error. The data is on fd 4. Where did fd 4 come from, and what is fd 3 still for?

The answer is that TCP has two different kinds of socket, and accept is the moment one of them manufactures the other. To see why, you need to see what the kernel does in the gap between connect on one machine and accept returning on the other.

Two kinds of socket

A listening socket is bound to a local address and port and does one job: it waits for handshakes. It has no peer. You cannot send on it. Its kernel object holds a queue of connections that have finished the handshake but have not yet been handed to your program.

A connected socket is the connection itself. It is identified by a 5-tuple: protocol, local IP, local port, remote IP, remote port. It has its own send buffer, its own receive buffer, its own sequence numbers. Every client that connects to your server gets its own connected socket, and therefore its own fd. The listening socket stays where it is and keeps listening.

💡
The listening socket is a factory. It never carries data. Each completed handshake comes off the line as a new connected socket, and accept is how you pick one up. A server with 1000 clients has 1001 sockets: one listener and 1000 connections.

Three packets

When the client calls connect, its kernel sends a segment with the SYN flag set and an initial sequence number, say 1000. "I want to talk. My bytes will start counting from 1000."

The server kernel, if something is listening on that port, replies with a segment that has both SYN and ACK set: its own initial sequence number, say 5000, and an acknowledgement number of 1001. "I heard your 1000. My bytes start at 5000." The server program is not involved. It is asleep inside accept, or busy doing something else. The kernel answers on its own.

The client kernel sends an ACK with acknowledgement 5001. Now both sides know the other side can hear them, and both know where the other's byte count starts. The connection is ESTABLISHED. connect returns 0 on the client. On the server, the connection is placed in the listening socket's queue, and the next accept returns a new fd for it.

tcp — three-way handshake and the fd table
Click next to start the walkthrough.

The sequence numbers are the foundation of everything TCP promises. Every byte sent has a number. The receiver acknowledges the next number it expects. If a segment is lost, its ACK never comes, and the sender's timer fires and retransmits. If segments arrive out of order, the receiver puts them back in order by number. You will never see any of this from your program. It is all in the kernel, and it is why recv on a TCP socket gives you bytes in the order they were sent.

The server side

c · tcp_server.c
#include <arpa/inet.h> #include <stdio.h> #include <string.h> #include <sys/socket.h> #include <unistd.h> int main(void) { // fd 3: will become the listener int lfd = socket(AF_INET, SOCK_STREAM, 0); if (lfd == -1) { perror("socket"); return 1; } int yes = 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes); struct sockaddr_in me = { 0 }; me.sin_family = AF_INET; me.sin_port = htons(9000); me.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(lfd, (struct sockaddr *)&me, sizeof me) == -1) { perror("bind"); return 1; } // 128 = backlog of completed handshakes if (listen(lfd, 128) == -1) { perror("listen"); return 1; } for (;;) { struct sockaddr_in peer; socklen_t plen = sizeof peer; // blocks until a handshake completes; NEW fd int cfd = accept(lfd, (struct sockaddr *)&peer, &plen); if (cfd == -1) { perror("accept"); continue; } char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof ip); printf("from %s:%u on fd %d\n", ip, ntohs(peer.sin_port), cfd); const char *hello = "hello from the server\n"; // data goes on cfd, never on lfd send(cfd, hello, strlen(hello), 0); close(cfd); // done with this one; lfd lives on } }

Read the loop carefully. accept is called on lfd and returns cfd. Data moves on cfd. cfd is closed. lfd is never closed; it lives for the life of the server. Each trip around the loop is one client.

⚠️
"Address already in use" after a restart is not a bug in your code. When a server closes connections, the kernel keeps the 5-tuples in TIME_WAIT for a minute or so to catch late packets. By default, bind refuses the port while any of those linger. SO_REUSEADDR tells the kernel you know what you are doing. Set it on every server socket before bind.

The client side

c · tcp_client.c
int fd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in srv = { 0 }; srv.sin_family = AF_INET; srv.sin_port = htons(9000); inet_pton(AF_INET, "203.0.113.9", &srv.sin_addr); // sends SYN, waits for SYN-ACK, sends ACK — or fails if (connect(fd, (struct sockaddr *)&srv, sizeof srv) == -1) { // ECONNREFUSED: got RST. ETIMEDOUT: got nothing. perror("connect"); return 1; } char buf[128]; ssize_t n = recv(fd, buf, sizeof buf, 0); if (n > 0) printf("%.*s", (int)n, buf); close(fd);

The client never calls bind. connect picks an ephemeral local port, performs the handshake, and returns when the connection is established. The same fd is used for the handshake and for data: a client socket goes straight from "nothing" to "connected".

When nobody is home

Send a SYN to a port with no listening socket and the server kernel replies with a segment whose RST flag is set. Reset: there is no such connection and there never will be. The client's connect returns -1 with errno == ECONNREFUSED. No program on the server ran. The last step of the visualization shows it.

If the SYN gets no reply at all, which is what a firewall that silently drops packets looks like, the client kernel retransmits the SYN a few times with growing delays and eventually gives up with ETIMEDOUT. On Linux that takes about two minutes. "Connection refused" is fast and honest. "Connection timed out" means something ate your packets.

🚫
Reading from the listening socket is an error, not an empty read. recv(lfd, ...) returns -1 with ENOTCONN. The listening socket has no peer and no receive buffer for data. If your server "receives nothing", check which fd you passed.

Closing is also a handshake

close on a connected socket sends a FIN: "I have no more bytes to send." The peer ACKs it. The peer can keep sending; the connection is now half-closed. When the peer closes too, it sends its own FIN and gets an ACK. Four segments to tear down what three built. The side that closed first sits in TIME_WAIT for a while in case a stray segment turns up late.

If you want the half-close without giving up the fd, shutdown(fd, SHUT_WR) sends the FIN but lets you keep reading. That is how a client says "here is my whole request" and then waits for the whole response.

✅
Every accept needs a matching close. Each connected socket is an fd, a kernel object, and two buffers. Forget to close them and a long-running server leaks fds until accept fails with EMFILE, the same leak you met in the file-descriptors lesson, wearing a network costume.
one-line takeaway

Three packets between two kernels create a connection; accept hands it to you as a new fd, and the listening socket never carries a byte.