Skip to content

Latest commit

 

History

History
694 lines (492 loc) · 29.7 KB

File metadata and controls

694 lines (492 loc) · 29.7 KB

🔝 Retour au Sommaire

22.1.2 — bind, listen, accept, connect

Section 22.1 : Sockets TCP/UDP — API POSIX


Introduction

Un socket fraîchement créé n'est qu'une coquille vide — une structure noyau avec des buffers et un protocole, mais sans adresse, sans port, sans rôle défini. C'est la séquence d'appels système qui suit la création qui donne au socket sa fonction : serveur qui écoute et accepte des connexions, ou client qui initie une connexion vers un serveur distant.

Cette section détaille les quatre opérations qui orchestrent l'établissement de la communication TCP, puis couvre les spécificités UDP. Chaque appel système est présenté avec sa signature, son rôle dans le cycle de vie, ses pièges courants et les bonnes pratiques C++ modernes associées.


Vue d'ensemble des rôles

Rappelons le schéma du cycle de vie TCP vu en section 22.1, en soulignant cette fois quel appel est exécuté par quel rôle :

         SERVEUR                                     CLIENT
    ┌──────────────────┐                     ┌──────────────────┐
    │  socket()        │                     │  socket()        │
    │  bind()    ◄─────┤ Serveur seulement   │                  │
    │  listen()  ◄─────┤                     │                  │
    │  accept()  ◄─────┤                     │  connect() ──────┤ Client seulement
    │  recv/send       │◄───────────────────►│  recv/send       │
    │  close()         │                     │  close()         │
    └──────────────────┘                     └──────────────────┘

Le serveur exécute bindlistenaccept (dans cet ordre, obligatoirement). Le client n'a besoin que de connect. Les deux côtés utilisent ensuite les mêmes fonctions d'envoi/réception (section 22.1.3).


bind() — Associer une adresse locale au socket

Signature

#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);

Rôle

bind() associe le socket à une adresse locale : une combinaison d'adresse IP et de port. C'est ce qui dit au noyau « quand un paquet arrive sur cette adresse et ce port, livre-le à ce socket ».

Sans bind(), le socket n'a pas d'adresse locale. Le noyau ne sait pas à quel trafic le rattacher.

Ce que vous bindez

Le port est le paramètre critique. C'est lui qui identifie votre service. Un serveur HTTP binde sur le port 80 ou 443, un serveur gRPC sur le port 50051, votre application personnalisée sur le port de votre choix.

L'adresse IP contrôle sur quelles interfaces réseau le socket écoute :

Adresse Signification
INADDR_ANY / in6addr_any Toutes les interfaces — c'est le choix par défaut pour les serveurs
127.0.0.1 / ::1 Loopback uniquement — accessible seulement depuis la machine locale
192.168.1.42 Une interface spécifique — le socket n'accepte le trafic que sur cette IP

Implémentation avec getaddrinfo

Plutôt que de remplir manuellement les structures sockaddr_in ou sockaddr_in6, utilisez le résultat de getaddrinfo (voir section 22.1.1) — c'est plus propre, portable et compatible IPv4/IPv6 :

// Résolution : nullptr comme host = INADDR_ANY / in6addr_any
auto addr = resolve(nullptr, "8080", AF_INET6, SOCK_STREAM);

Socket server{addr->ai_family, addr->ai_socktype, addr->ai_protocol};  
server.enable_reuse_addr();  

if (bind(server.fd(), addr->ai_addr, addr->ai_addrlen) == -1) {
    throw std::system_error(errno, std::system_category(), "bind()");
}

Implémentation manuelle (IPv4)

Pour les cas où vous avez besoin de contrôle fin ou pour comprendre ce qui se passe sous le capot :

sockaddr_in addr{};  
addr.sin_family = AF_INET;  
addr.sin_port = htons(8080);          // N'oubliez JAMAIS htons()  
addr.sin_addr.s_addr = INADDR_ANY;    // Toutes les interfaces  

if (bind(sockfd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == -1) {
    throw std::system_error(errno, std::system_category(), "bind()");
}

Implémentation manuelle (IPv6 dual-stack)

sockaddr_in6 addr{};  
addr.sin6_family = AF_INET6;  
addr.sin6_port = htons(8080);  
addr.sin6_addr = in6addr_any;  // Équivalent de INADDR_ANY pour IPv6  

// Désactiver V6ONLY pour accepter aussi les connexions IPv4
int opt = 0;  
setsockopt(sockfd, IPPROTO_IPV6, IPV6_V6ONLY, &opt, sizeof(opt));  

if (bind(sockfd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == -1) {
    throw std::system_error(errno, std::system_category(), "bind()");
}

Erreurs courantes

errno Signification Cause et solution
EADDRINUSE Adresse déjà utilisée Un autre processus écoute sur ce port, ou le port est en TIME_WAIT. Solution : SO_REUSEADDR (voir section 22.1.1), ou choisir un autre port.
EACCES Permission refusée Tentative de bind sur un port < 1024 sans privilèges root (ou CAP_NET_BIND_SERVICE).
EADDRNOTAVAIL Adresse non disponible L'adresse IP spécifiée n'existe sur aucune interface de la machine.

⚠️ Le piège des ports privilégiés — Les ports 1 à 1023 sont historiquement réservés et nécessitent les privilèges root. En production, ne lancez jamais votre serveur en root. Utilisez plutôt un port non-privilégié (≥ 1024) combiné à un reverse proxy (Nginx, HAProxy), ou attribuez la capability CAP_NET_BIND_SERVICE au binaire avec setcap.

Bind et UDP

Pour un serveur UDP, bind() fonctionne exactement de la même manière. La différence est qu'il n'y aura pas de listen() ni accept() ensuite — le socket est immédiatement prêt à recevoir des datagrammes avec recvfrom().

Le client a-t-il besoin de bind() ?

Non, en général. Quand un client appelle connect() (TCP) ou sendto() (UDP) sans avoir fait de bind() préalable, le noyau attribue automatiquement un port éphémère et l'adresse de l'interface de sortie. C'est le comportement normal et souhaité.

Un client n'appelle bind() que dans des cas très spécifiques : forcer l'utilisation d'une interface réseau particulière sur une machine multi-homed, ou utiliser un port source fixe (rare, parfois nécessaire pour traverser certains firewalls).


listen() — Mettre le socket en mode écoute

Signature

#include <sys/socket.h>

int listen(int sockfd, int backlog);

Rôle

listen() transforme un socket bindé en un socket d'écoute (listening socket). C'est une transition d'état irréversible : après listen(), le socket ne pourra plus être utilisé pour envoyer ou recevoir des données lui-même. Son unique rôle sera d'attendre des connexions entrantes, que vous récupérerez avec accept().

Concrètement, listen() demande au noyau de commencer à accepter les demandes de connexion TCP (SYN) sur l'adresse et le port bindés, et de les placer dans une file d'attente en attendant que l'application appelle accept().

if (listen(server.fd(), SOMAXCONN) == -1) {
    throw std::system_error(errno, std::system_category(), "listen()");
}
// Le socket est maintenant en écoute — les clients peuvent se connecter

Le backlog : anatomie de la file d'attente

Le paramètre backlog contrôle la taille de la file d'attente des connexions en attente. Pour comprendre son rôle, il faut connaître le mécanisme interne du noyau.

Quand un client initie une connexion TCP, le three-way handshake se déroule entièrement dans le noyau, sans intervention de l'application :

Client                    Noyau serveur                     Application
──────                    ─────────────                     ───────────

  ── SYN ──────────────►  File SYN (semi-ouvertes)
                          Le noyau répond SYN-ACK
  ◄── SYN-ACK ──────────
  ── ACK ──────────────►  File ACCEPT (établies)  ────────►  accept()
                          Connexion prête

Le noyau Linux maintient en réalité deux files par socket d'écoute :

La SYN queue (file des connexions semi-ouvertes) — Contient les connexions pour lesquelles le noyau a reçu le SYN et envoyé le SYN-ACK, mais n'a pas encore reçu l'ACK final. La taille de cette file est contrôlée par tcp_max_syn_backlog (par défaut 128 ou 1024 selon les distributions).

L'accept queue (file des connexions établies) — Contient les connexions dont le handshake est terminé (ACK reçu), mais que l'application n'a pas encore récupérées avec accept(). C'est cette file dont la taille est contrôlée par le paramètre backlog de listen().

Quand la file accept est pleine, le comportement dépend de la configuration du noyau (tcp_abort_on_overflow). Par défaut, le noyau ignore silencieusement les ACK supplémentaires — le client pense que la connexion est établie, mais le serveur ne la voit pas. Le client finira par retransmettre et la connexion aboutira si de la place se libère, ou échouera par timeout.

Quelle valeur de backlog choisir ?

SOMAXCONN — C'est la valeur maximale autorisée par le système. Sur les noyaux Linux récents, elle vaut 4096 (définie dans /proc/sys/net/core/somaxconn). C'est le choix recommandé pour les serveurs de production.

listen(server.fd(), SOMAXCONN);  // Recommandé

Si vous passez une valeur supérieure à SOMAXCONN, le noyau la tronque silencieusement à SOMAXCONN. Si vous passez une valeur trop faible, vous risquez de perdre des connexions en cas de pic de trafic.

En développement, une valeur plus petite (16, 32) peut suffire. Mais il n'y a aucun avantage à utiliser une petite valeur — SOMAXCONN ne consomme pas significativement plus de mémoire.

Ajustement en production

Pour un serveur haute performance, augmentez somaxconn :

# Valeur actuelle
cat /proc/sys/net/core/somaxconn

# Augmenter (temporaire)
sudo sysctl -w net.core.somaxconn=65535

# Augmenter la SYN queue aussi
sudo sysctl -w net.ipv4.tcp_max_syn_backlog=65535

listen() est TCP uniquement

UDP n'a pas de notion de connexion, donc pas de listen(). Un socket UDP est prêt à recevoir des datagrammes dès qu'il est bindé.


accept() — Récupérer une connexion entrante

Signature

#include <sys/socket.h>

int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen);

// Version Linux avec flags (préférée)
int accept4(int sockfd, struct sockaddr* addr, socklen_t* addrlen, int flags);

Rôle

accept() extrait la première connexion de la file d'attente du socket d'écoute et crée un nouveau socket dédié à cette connexion. C'est un point fondamental : le socket d'écoute n'est jamais utilisé pour communiquer avec un client. Chaque appel à accept() retourne un nouveau file descriptor qui représente la connexion bidirectionnelle avec un client spécifique.

Socket d'écoute (fd=3)              Sockets de connexion  
port 8080                           (un par client)  
┌─────────────┐
│  listen()   │──── accept() ────► fd=4  ◄──► Client A (192.168.1.10:49152)
│             │──── accept() ────► fd=5  ◄──► Client B (10.0.0.5:52301)
│             │──── accept() ────► fd=6  ◄──► Client C (172.16.0.8:38777)
│  port 8080  │
└─────────────┘

Le socket d'écoute (fd=3) reste ouvert et continue d'accepter de nouvelles connexions. Chaque socket client (fd=4, 5, 6) est indépendant et sera utilisé pour send() / recv() avec son client respectif.

Comportement bloquant vs non-bloquant

Mode bloquant (défaut) — Si la file d'attente est vide (aucun client en attente), accept() bloque le thread appelant jusqu'à ce qu'une connexion arrive. C'est le comportement naturel pour un serveur simple mono-thread.

Mode non-bloquant — Si le socket d'écoute a été créé avec SOCK_NONBLOCK ou configuré avec fcntl(), accept() retourne immédiatement -1 avec errno = EAGAIN quand il n'y a aucune connexion en attente. C'est le mode utilisé avec les mécanismes de multiplexage (epoll, section 22.3).

Utilisation de base

sockaddr_storage client_addr{};  
socklen_t addr_len = sizeof(client_addr);  

int client_fd = accept4(
    server.fd(),
    reinterpret_cast<sockaddr*>(&client_addr),
    &addr_len,
    SOCK_CLOEXEC  // Atomique : pas de race condition
);

if (client_fd == -1) {
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
        // Socket non-bloquant, pas de connexion en attente — normal
    } else {
        throw std::system_error(errno, std::system_category(), "accept4()");
    }
}

// Encapsuler immédiatement en RAII
Socket client{client_fd};

Récupérer les informations du client

accept() remplit la structure sockaddr avec l'adresse du client qui s'est connecté. Pour l'exploiter (logging, contrôle d'accès, diagnostics) :

#include <arpa/inet.h>

void log_client_info(const sockaddr_storage& addr) {
    char ip_str[INET6_ADDRSTRLEN];
    uint16_t port;

    if (addr.ss_family == AF_INET) {
        auto* v4 = reinterpret_cast<const sockaddr_in*>(&addr);
        inet_ntop(AF_INET, &v4->sin_addr, ip_str, sizeof(ip_str));
        port = ntohs(v4->sin_port);
    } else {  // AF_INET6
        auto* v6 = reinterpret_cast<const sockaddr_in6*>(&addr);
        inet_ntop(AF_INET6, &v6->sin6_addr, ip_str, sizeof(ip_str));
        port = ntohs(v6->sin6_port);
    }

    std::println("Connexion depuis {}:{}", ip_str, port);
}

inet_ntop convertit une adresse binaire en chaîne lisible (le p signifie « presentation », le n signifie « network »). C'est la fonction inverse d'inet_pton. Utilisez INET6_ADDRSTRLEN (46 octets) comme taille de buffer — c'est suffisant pour les deux familles.

La boucle d'accept classique

Un serveur ne fait pas qu'un seul accept(). Il tourne en boucle, acceptant les connexions une par une (ou en les dispatchant à des threads/tâches) :

while (running) {
    sockaddr_storage client_addr{};
    socklen_t addr_len = sizeof(client_addr);

    int client_fd = accept4(
        server.fd(),
        reinterpret_cast<sockaddr*>(&client_addr),
        &addr_len,
        SOCK_CLOEXEC
    );

    if (client_fd == -1) {
        if (errno == EINTR) {
            continue;  // Interrompu par un signal — réessayer
        }
        throw std::system_error(errno, std::system_category(), "accept4()");
    }

    Socket client{client_fd};
    log_client_info(client_addr);

    // Traiter la connexion :
    // - Directement (serveur séquentiel, simple mais lent)
    // - Dans un nouveau thread (serveur multi-threadé)
    // - Via epoll (serveur événementiel, section 22.3)
    handle_client(std::move(client));
}

💡 EINTR — Quand un signal est délivré au processus pendant un appel bloquant, l'appel système peut être interrompu et retourner -1 avec errno = EINTR. Ce n'est pas une erreur : il suffit de relancer l'appel. Vous verrez ce pattern dans tout code réseau POSIX.

Erreurs courantes

errno Signification Notes
EAGAIN / EWOULDBLOCK Pas de connexion en attente Normal en mode non-bloquant
EINTR Interrompu par un signal Relancer l'appel
EMFILE Limite de fd du processus atteinte Augmenter ulimit -n
ENFILE Limite de fd système atteinte Augmenter fs.file-max
ECONNABORTED Connexion avortée par le client Le client a fermé avant la fin du handshake — ignorer et continuer

accept() est TCP uniquement

Comme listen(), accept() n'a pas de sens en UDP. Un socket UDP n'a pas de notion de connexion individuelle — tous les datagrammes arrivent sur le même socket.


connect() — Initier une connexion (côté client)

Signature

#include <sys/socket.h>

int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);

Rôle

connect() est l'opération symétrique d'accept() — c'est le client qui l'appelle pour initier une connexion vers un serveur. Pour TCP, connect() déclenche le three-way handshake. L'appel bloque (par défaut) jusqu'à ce que la connexion soit établie ou qu'une erreur survienne.

Connexion TCP avec getaddrinfo

La manière idiomatique de se connecter est d'itérer sur les résultats de getaddrinfo jusqu'à trouver une adresse qui fonctionne. C'est important pour la robustesse : un nom DNS peut résoudre vers plusieurs adresses (IPv4 et IPv6, plusieurs serveurs), et certaines peuvent être injoignables :

addrinfo hints{};  
hints.ai_family = AF_UNSPEC;      // IPv4 ou IPv6  
hints.ai_socktype = SOCK_STREAM;  // TCP  

addrinfo* result = nullptr;  
int status = getaddrinfo("api.example.com", "443", &hints, &result);  
if (status != 0) {  
    throw std::runtime_error(
        std::string("getaddrinfo: ") + gai_strerror(status)
    );
}
AddrInfoPtr addr_list{result};  // RAII (voir section 22.1.1)

// Parcourir les résultats et tenter chaque adresse
int sockfd = -1;  
for (auto* rp = addr_list.get(); rp != nullptr; rp = rp->ai_next) {  
    sockfd = socket(rp->ai_family,
                    rp->ai_socktype | SOCK_CLOEXEC,
                    rp->ai_protocol);
    if (sockfd == -1) {
        continue;  // Cette combinaison n'est pas supportée — essayer la suivante
    }

    if (connect(sockfd, rp->ai_addr, rp->ai_addrlen) == 0) {
        break;  // Connexion réussie
    }

    // Échec de connect — fermer et essayer l'adresse suivante
    close(sockfd);
    sockfd = -1;
}

if (sockfd == -1) {
    throw std::runtime_error("Impossible de se connecter à api.example.com:443");
}

Socket connection{sockfd};  // Encapsuler en RAII
// La connexion est établie — prêt pour send()/recv()

Ce pattern d'itération est fondamental. Le RFC 6555 (« Happy Eyeballs ») va même plus loin en recommandant de tenter les connexions IPv6 et IPv4 en parallèle pour minimiser la latence perçue. Les librairies comme Asio implémentent ce mécanisme automatiquement.

Connexion non-bloquante

En mode non-bloquant, connect() retourne immédiatement -1 avec errno = EINPROGRESS. La connexion se poursuit en arrière-plan, et vous devez utiliser poll() ou epoll pour détecter quand elle aboutit :

// Socket non-bloquant
Socket sock{AF_INET6, SOCK_STREAM | SOCK_NONBLOCK};

int ret = connect(sock.fd(), addr->ai_addr, addr->ai_addrlen);  
if (ret == -1 && errno != EINPROGRESS) {  
    throw std::system_error(errno, std::system_category(), "connect()");
}

if (ret == -1) {
    // Connexion en cours — attendre avec poll()
    pollfd pfd{};
    pfd.fd = sock.fd();
    pfd.events = POLLOUT;  // Écriture possible = connexion établie

    int poll_ret = poll(&pfd, 1, 5000);  // Timeout 5 secondes
    if (poll_ret == 0) {
        throw std::runtime_error("connect() timeout");
    }
    if (poll_ret == -1) {
        throw std::system_error(errno, std::system_category(), "poll()");
    }

    // Vérifier le résultat réel de la connexion
    int error = 0;
    socklen_t len = sizeof(error);
    getsockopt(sock.fd(), SOL_SOCKET, SO_ERROR, &error, &len);
    if (error != 0) {
        throw std::system_error(error, std::system_category(), "connect()");
    }
}

// Connexion établie

L'étape getsockopt(SO_ERROR) est cruciale et souvent oubliée. Le fait que poll() signale le socket comme prêt en écriture ne signifie pas que la connexion a réussi — cela signifie seulement que le résultat est disponible. L'erreur réelle (refus de connexion, hôte injoignable, etc.) est stockée dans SO_ERROR.

connect() et UDP

En UDP, connect() a un sens différent. Il n'établit pas de connexion (il n'y a pas de handshake), mais il associe une adresse de destination au socket. Les conséquences sont :

  • Vous pouvez utiliser send() et recv() au lieu de sendto() et recvfrom() — plus simple.
  • Le noyau filtre les datagrammes entrants : seuls ceux provenant de l'adresse connectée sont délivrés.
  • Les erreurs ICMP (destination injoignable, etc.) sont reportées à l'application — ce qui n'est pas le cas sans connect().
// UDP "connecté" — pratique pour un client qui parle à un seul serveur
Socket udp_client{AF_INET6, SOCK_DGRAM};  
connect(udp_client.fd(), server_addr->ai_addr, server_addr->ai_addrlen);  

// Désormais, send/recv au lieu de sendto/recvfrom
send(udp_client.fd(), data, len, 0);  
recv(udp_client.fd(), buffer, sizeof(buffer), 0);  

Erreurs courantes de connect()

errno Signification Diagnostic
ECONNREFUSED Connexion refusée Le serveur n'écoute pas sur ce port. Vérifier que le serveur tourne et le port est correct.
ETIMEDOUT Timeout Le serveur est injoignable (firewall, réseau coupé). Vérifier la connectivité réseau.
ENETUNREACH Réseau injoignable Pas de route vers le réseau de destination. Vérifier la table de routage.
EHOSTUNREACH Hôte injoignable L'hôte spécifique ne répond pas.
EINPROGRESS Connexion en cours Socket non-bloquant — la connexion est en cours, pas une erreur.
EALREADY Connexion déjà en cours Un connect() non-bloquant précédent n'est pas encore terminé.
EISCONN Socket déjà connecté Le socket est déjà connecté — vous ne pouvez pas appeler connect() deux fois sur un socket TCP.

Timeouts de connexion

Le timeout par défaut de connect() est contrôlé par le noyau et peut atteindre 2 minutes (plusieurs retransmissions du SYN avec backoff exponentiel). C'est beaucoup trop long pour la plupart des applications.

Deux approches pour imposer un timeout plus court :

Approche 1 : Socket non-bloquant + poll — C'est la méthode montrée ci-dessus. Vous contrôlez le timeout via le paramètre de poll().

Approche 2 : SO_SNDTIMEO — Positionner un timeout d'envoi sur le socket avant connect(). Moins propre que l'approche non-bloquante, mais plus simple :

timeval tv{};  
tv.tv_sec = 5;   // 5 secondes  
tv.tv_usec = 0;  
setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));  

// connect() retournera EINPROGRESS ou ETIMEDOUT après 5 secondes

Assembler le tout : un serveur TCP minimal

Voici comment les quatre opérations s'enchaînent dans un serveur TCP complet et minimal. Ce code utilise le wrapper RAII Socket et la fonction resolve() de la section 22.1.1 :

#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <poll.h>
#include <unistd.h>
#include <print>

// (Socket, AddrInfoPtr et resolve() définis en section 22.1.1)

void handle_client(Socket client) {
    // Sera détaillé en section 22.1.3
    char buffer[4096];
    ssize_t n = recv(client.fd(), buffer, sizeof(buffer), 0);
    if (n > 0) {
        send(client.fd(), buffer, n, 0);  // Echo
    }
    // client est fermé automatiquement (RAII)
}

int main() {
    // 1. Résoudre l'adresse
    auto addr = resolve(nullptr, "8080", AF_INET6, SOCK_STREAM);

    // 2. Créer le socket
    Socket server{addr->ai_family, addr->ai_socktype, addr->ai_protocol};

    // 3. Configurer les options
    server.enable_reuse_addr();

    // 4. Binder sur le port
    if (bind(server.fd(), addr->ai_addr, addr->ai_addrlen) == -1) {
        throw std::system_error(errno, std::system_category(), "bind()");
    }

    // 5. Passer en mode écoute
    if (listen(server.fd(), SOMAXCONN) == -1) {
        throw std::system_error(errno, std::system_category(), "listen()");
    }

    std::println("Serveur en écoute sur le port 8080...");

    // 6. Boucle d'accept
    while (true) {
        sockaddr_storage client_addr{};
        socklen_t addr_len = sizeof(client_addr);

        int client_fd = accept4(
            server.fd(),
            reinterpret_cast<sockaddr*>(&client_addr),
            &addr_len,
            SOCK_CLOEXEC
        );

        if (client_fd == -1) {
            if (errno == EINTR) continue;
            throw std::system_error(errno, std::system_category(), "accept4()");
        }

        // 7. Traiter le client (ici de manière séquentielle)
        handle_client(Socket{client_fd});
    }
}

Et le client correspondant :

#include <sys/socket.h>
#include <unistd.h>
#include <cstring>
#include <print>

int main() {
    // 1. Résoudre l'adresse du serveur
    auto addr = resolve("localhost", "8080", AF_UNSPEC, SOCK_STREAM);

    // 2. Itérer sur les résultats et se connecter
    int sockfd = -1;
    for (auto* rp = addr.get(); rp != nullptr; rp = rp->ai_next) {
        sockfd = socket(rp->ai_family,
                        rp->ai_socktype | SOCK_CLOEXEC,
                        rp->ai_protocol);
        if (sockfd == -1) continue;

        if (connect(sockfd, rp->ai_addr, rp->ai_addrlen) == 0) {
            break;
        }
        close(sockfd);
        sockfd = -1;
    }

    if (sockfd == -1) {
        std::println(stderr, "Impossible de se connecter");
        return 1;
    }

    Socket connection{sockfd};

    // 3. Envoyer et recevoir
    const char* msg = "Hello, server!";
    send(connection.fd(), msg, strlen(msg), 0);

    char buffer[4096];
    ssize_t n = recv(connection.fd(), buffer, sizeof(buffer) - 1, 0);
    if (n > 0) {
        buffer[n] = '\0';
        std::println("Réponse du serveur : {}", buffer);
    }

    // connection est fermé automatiquement (RAII)
}

⚠️ Ce serveur est séquentiel : il ne traite qu'un client à la fois. Pendant qu'il gère un client, tous les autres attendent dans la file du backlog. C'est acceptable pour un prototype ou un outil interne, mais inadapté pour la production. La section 22.2 construit un serveur plus réaliste, et la section 22.3 introduit epoll pour gérer des milliers de connexions simultanées.


Fermeture : close() et shutdown()

close()

La fermeture d'un socket se fait avec le même close() que pour un fichier :

close(sockfd);

Avec le wrapper RAII, vous n'appelez jamais close() manuellement — le destructeur s'en charge. Mais il est utile de comprendre ce qui se passe en interne.

Pour un socket TCP, close() déclenche la séquence de terminaison TCP (FIN → ACK). Si des données restent dans le buffer d'envoi, le comportement dépend de l'option SO_LINGER :

  • Par défaut (SO_LINGER désactivé) — close() retourne immédiatement. Le noyau continue d'envoyer les données restantes en arrière-plan.
  • SO_LINGER activé avec timeout > 0close() bloque jusqu'à ce que toutes les données soient envoyées, ou que le timeout expire.
  • SO_LINGER activé avec timeout = 0 — Le noyau envoie un RST (reset) immédiat. Toutes les données non envoyées sont perdues. Utile pour les fermetures brutales volontaires.

shutdown() — Fermeture partielle

shutdown() offre un contrôle plus fin que close() en permettant de fermer un seul sens de la communication :

#include <sys/socket.h>

int shutdown(int sockfd, int how);
how Effet
SHUT_RD Ferme la lecture — les données entrantes suivantes seront ignorées
SHUT_WR Ferme l'écriture — envoie un FIN au pair. Le pair verra EOF sur son recv()
SHUT_RDWR Ferme les deux sens — équivalent à close() pour la connexion, mais ne libère pas le fd

Le cas d'usage le plus courant est SHUT_WR : « j'ai fini d'envoyer, mais je veux encore recevoir la réponse du serveur ». C'est le mécanisme propre pour signaler la fin d'un flux de données au pair :

// Client : j'ai fini d'envoyer
shutdown(sock.fd(), SHUT_WR);

// Je peux encore recevoir la réponse
char buffer[4096];  
ssize_t n = recv(sock.fd(), buffer, sizeof(buffer), 0);  

La différence clé entre shutdown(SHUT_RDWR) et close() : shutdown agit sur la connexion (partagée entre descripteurs dupliqués), tandis que close agit sur le file descriptor (une seule référence). Si un socket a été dup()é ou transmis via fork(), close() d'un côté ne ferme pas la connexion tant que l'autre fd reste ouvert. shutdown() ferme la connexion pour tous les descripteurs qui la partagent.


Résumé

Les quatre opérations bind, listen, accept et connect forment le protocole d'établissement de toute communication TCP sous Linux. Voici les points essentiels à retenir :

  • bind() associe une adresse locale au socket. Utilisez getaddrinfo plutôt qu'un remplissage manuel. Le client n'a généralement pas besoin de binder.
  • listen() active le mode écoute et crée les files d'attente du noyau. Passez SOMAXCONN comme backlog en production, et ajustez somaxconn au niveau système si nécessaire.
  • accept() / accept4() extrait une connexion établie et retourne un nouveau socket dédié. Utilisez accept4 avec SOCK_CLOEXEC pour éviter les race conditions. Gérez EINTR dans la boucle d'accept.
  • connect() initie la connexion côté client. Itérez sur les résultats de getaddrinfo pour la robustesse. En mode non-bloquant, vérifiez SO_ERROR après que poll signale le socket prêt.
  • shutdown() offre un contrôle fin sur la fermeture partielle, complémentaire à close().

Prochaine étape → Section 22.1.3 : send, recv, sendto, recvfrom — l'échange de données proprement dit, avec la gestion des envois partiels, des signaux et des subtilités TCP vs UDP.

⏭️ send, recv, sendto, recvfrom