Aller au contenu

Réseau & sockets

Toute la communication du serveur passe par TCP, de façon non bloquante et multiplexée. Cette page décrit le chemin complet d'un octet : depuis la création de la socket d'écoute jusqu'au dispatch d'une commande IA, en passant par la hiérarchie de clients et les tampons.

Création de la socket d'écoute

createServerSocket(port) construit une socket TCP IPv4 classique, mais avec deux réglages essentiels : SO_REUSEADDR (pour pouvoir redémarrer le serveur immédiatement sans attendre le TIME_WAIT) et le passage en mode non bloquant.

// network/SocketUtils.cpp:24-48
int createServerSocket(int port)
{
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    sockaddr_in addr{};
    ...
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(port);
    bind(fd, ...);
    listen(fd, SOMAXCONN);
    setNonBlocking(fd);
    return fd;
}

Le passage non bloquant se fait via fcntl :

// network/SocketUtils.cpp:14-22
void setNonBlocking(int fd)
{
    int flags = fcntl(fd, F_GETFL, 0);
    ...
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

Pourquoi non bloquant

Avec une seule socket bloquante, un client lent figerait tout le serveur. En mode non bloquant, recv/send/accept reviennent immédiatement avec EAGAIN/EWOULDBLOCK plutôt que d'attendre. Combiné à poll(), cela permet de gérer des centaines de clients dans un unique thread sans jamais se bloquer sur l'un d'eux.

La boucle poll()

Le serveur maintient un vecteur std::vector<pollfd> _pollFds (Server.hpp:51). La socket d'écoute y est ajoutée en premier avec POLLIN. Avant chaque poll(), updatePollEvents() recalcule les événements surveillés pour chaque client :

  • POLLIN est toujours armé (on veut toujours pouvoir lire) ;
  • POLLOUT n'est armé que si le client a des données en attente d'écriture (hasPendingWrite()).
// core/Server.cpp:635-648
void Server::updatePollEvents()
{
    for (pollfd &pfd : _pollFds) {
        if (pfd.fd == _serverFd) {
            pfd.events = POLLIN;
            continue;
        }
        pfd.events = POLLIN;
        auto it = _clients.find(pfd.fd);
        if (it != _clients.end() && it->second->hasPendingWrite())
            pfd.events |= POLLOUT;
    }
}

POLLOUT à la demande

Armer POLLOUT en permanence ferait revenir poll() en boucle dès que la socket est inscriptible (presque toujours), donc à vide. En ne l'armant que lorsqu'il reste réellement quelque chose à envoyer, le serveur évite de tourner inutilement.

Le timeout de poll() provient du Scheduler (voir Boucle de jeu & temps). À chaque réveil, le serveur copie _pollFds, itère sur les descripteurs prêts et les traite :

flowchart TD
    poll["poll() se réveille"]
    err{"POLLERR / POLLHUP / POLLNVAL ?"}
    srv{"fd == socket d'écoute ?"}
    rd{"POLLIN ?"}
    wr{"POLLOUT ?"}

    poll --> err
    err -->|oui| remove["removeClient(fd)"]
    err -->|non| srv
    srv -->|oui| accept["acceptClient()<br/>(différé en fin d'itération)"]
    srv -->|non| rd
    rd -->|oui| read["readClient(fd)"]
    rd -->|non| wr
    read --> wr
    wr -->|oui| write["writeClient(fd)"]

L'accept est différé : on lève un drapeau shouldAccept et on appelle acceptClient() une fois la boucle de fds terminée, afin de ne pas modifier _clients/_pollFds pendant l'itération.

Accepter une connexion

// core/Server.cpp:425-438
void Server::acceptClient()
{
    int clientFd = accept(_serverFd, nullptr, nullptr);
    if (clientFd == -1)
        return;
    setNonBlocking(clientFd);
    _clients[clientFd] = new PendingClient(clientFd);
    _pollFds.push_back({clientFd, POLLIN | POLLOUT, 0});
}

Chaque nouvelle connexion devient un PendingClient : un client dont on ne sait pas encore s'il sera une IA ou le GUI. Sa construction envoie immédiatement le message d'accueil.

La hiérarchie de clients

classDiagram
    class AClient {
        <<abstract>>
        #int _fd
        #NetworkBuffer _readBuf
        #NetworkBuffer _writeBuf
        #bool _shouldClose
        +appendRead(data, size)
        +flushWrite() bool
        +nextLine(line) bool
        +queueWrite(data)
        +hasPendingWrite() bool
        +markForClose()
        +handleInput()* 
        +getType()* ClientType
    }
    class PendingClient {
        +handleInput()
        +isHandshakeDone() bool
        +getHandshakeName() string
    }
    class AIClient {
        -string _teamName
        -Player* _player
        -deque~string~ _pendingLines
        -bool _busy
        +handleInput()
        +popPendingLine() string
        +isBusy() bool
    }
    class GUIClient {
        +handleInput()
        +pushEvent(msg)
        +popCommand(line) bool
    }
    AClient <|-- PendingClient
    AClient <|-- AIClient
    AClient <|-- GUIClient
  • AClient (abstrait) factorise tout le réseau : descripteur _fd, tampons de lecture/écriture NetworkBuffer, drapeau _shouldClose, et les méthodes appendRead / flushWrite / nextLine / queueWrite / hasPendingWrite / markForClose. Il déclare deux méthodes virtuelles pures : handleInput() et getType().
  • PendingClient envoie WELCOME\n à sa construction et attend une ligne (le handshake) :
// network/PendingClient.cpp:15-29
PendingClient::PendingClient(int fd) : AClient(fd)
{
    queueWrite("WELCOME\n");
}
void PendingClient::handleInput()
{
    std::string line;
    if (nextLine(line)) {
        _handshakeDone = true;
        _handshakeName = line;
    }
}

Selon le nom reçu (GRAPHIC ou un nom d'équipe), ClientSessionManager promeut le PendingClient en GUIClient ou en AIClient. - AIClient possède un Player*, une std::deque<std::string> _pendingLines (les lignes brutes en attente de parsing) et un drapeau _busy indiquant qu'une commande est en cours d'exécution côté serveur. - GUIClient reçoit des événements via pushEvent (mis en file d'écriture) et expose ses commandes entrantes via popCommand.

Pour le détail du handshake (WELCOME → nom d'équipe → CLIENT-NUM puis X Y), voir Protocole.

Les tampons : NetworkBuffer

Chaque client a deux NetworkBuffer, un en lecture et un en écriture. Un NetworkBuffer enveloppe une std::string avec une borne de 64 Kio (MAX_BUFFER_SIZE = 65536, AClient.hpp:26). Tout ajout qui dépasserait cette borne échoue et marque le client pour fermeture — c'est une protection contre un client qui inonderait le serveur.

// network/NetworkBuffer.cpp:8-17
bool NetworkBuffer::append(const char *data, std::size_t size)
{
    if (_buffer.size() + size > _maxSize) {
        _tooLarge = true;
        return false;
    }
    _buffer.append(data, size);
    return true;
}

L'extraction de ligne (popLine) coupe au premier \n et retire un éventuel \r final (tolérance aux fins de ligne Windows) :

// network/NetworkBuffer.cpp:24-38
bool NetworkBuffer::popLine(std::string &line)
{
    std::size_t pos = _buffer.find('\n');
    if (pos == std::string::npos)
        return false;
    line = _buffer.substr(0, pos);
    if (!line.empty() && line.back() == '\r')
        line.pop_back();
    _buffer.erase(0, pos + 1);
    return true;
}

Le chemin lecture / écriture

LecturereadClient(fd) lit dans un tampon local de 4096 octets :

// core/Server.cpp:440-460 (extrait)
char buffer[4096];
ssize_t bytes = recv(fd, buffer, sizeof(buffer), 0);
if (bytes < 0) {
    if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)
        return;
    removeClient(fd);
    return;
}
if (bytes == 0) {       // pair fermé proprement
    removeClient(fd);
    return;
}
_clients[fd]->appendRead(buffer, bytes);

Après l'appendRead, le serveur appelle handleInput() (qui découpe les lignes), puis dispatche selon le type de client :

  • IAAICommandDispatcher::handleIncomingLines ;
  • PendingClientSessionManager::upgradePendingClient (promotion) ;
  • GUIGuiCommandHandler::handleCommands.

ÉcriturewriteClient(fd) appelle flushWrite(), qui fait un send non bloquant et retire du tampon uniquement les octets effectivement envoyés :

// network/AClient.cpp:31-60 (extrait)
ssize_t bytes = send(_fd, _writeBuf.str().c_str(), _writeBuf.size(), 0);
if (bytes < 0) {
    if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)
        return true;            // on réessaiera au prochain POLLOUT
    markForClose();             // EPIPE / ECONNRESET / autre
    return false;
}
_writeBuf.erase(0, bytes);

Si tout n'a pas pu partir, le reste demeure dans le tampon ; POLLOUT restera armé et la suite sera envoyée au prochain réveil. La fermeture est différée : un client marqué shouldClose() n'est réellement détruit qu'une fois son tampon d'écriture vidé, ce qui garantit que le dernier message (par exemple dead\n) parte bien avant la coupure.

Le AICommandDispatcher et la règle des 10 commandes

Chaque client IA possède un état AICommandState { deque<ACommand*> commands; int pendingResponses; }. Quand des lignes arrivent, elles sont parsées une à une. Le sujet impose qu'un client ne puisse avoir au plus 10 commandes en attente : au-delà, le serveur rejette la commande avec ko\n sans la mettre en file.

// core/AICommandDispatcher.cpp:21-43
void AICommandDispatcher::handleIncomingLines(AIClient &ai)
{
    while (ai.hasPendingLine()) {
        std::string line = ai.popPendingLine();
        ACommand *cmd = _parseCommand(line);

        if (!cmd) {                       // ligne non reconnue
            ai.queueWrite("ko\n");
            continue;
        }
        AICommandState &state = _states[ai.getFd()];
        if (state.pendingResponses >= 10) {   // file pleine
            ai.queueWrite("ko\n");
            delete cmd;
            continue;
        }
        state.commands.push_back(cmd);
        state.pendingResponses++;
    }
}

Deux raisons d'obtenir ko

Un ko\n peut signifier soit que la ligne est non analysable (commande inconnue ou arguments invalides), soit que la file de 10 est déjà pleine. Le compteur pendingResponses est décrémenté à mesure que chaque commande aboutit et que sa réponse part.

La boucle principale ne consomme qu'une commande par client IA et par itération, et seulement si le client n'est ni occupé (isBusy) ni en pleine incantation. Le délai de la commande détermine à quel moment futur sa réponse sera produite — c'est le sujet de la page Boucle de jeu & temps et de Commandes & délais.