4  Évaluation 1

Déroulement de la séance

  • Évaluation sur machine : 3 heures, soit la totalité de la séance.
  • La correction commentée sera faite au début de la séance 5.

4.1 Modalités

Conditions

Durée 3 heures
Lieu Salle de TP, sur les machines de l’école
Programme Séances 1, 2 et 3
Autorisé Ce cours (disponible sur les machines), vos notes manuscrites, votre éditeur, Python
Interdit Internet, messagerie, assistant IA (Copilot et autres), tout échange entre étudiants
Réseau coupé : tout s’exécute sur votre seule machine, en 127.0.0.1, sur plusieurs ports
Barème Partie 1 : 10 points — Partie 2 : 10 points
AvertissementAttention

Comme le réseau est coupé, ssh et scp sont inutilisables. Les sujets sont conçus pour cela : plusieurs processus sur votre machine, chacun sur son port, suffisent à constituer un système distribué. Entraînez-vous de cette façon dès les TP.

Ce qui est rendu

Vous rendez un unique fichier texte qui contient tout : vos réponses aux questions et l’intégralité de votre code. Pendant l’épreuve vous travaillez normalement, dans de vrais fichiers .py que vous exécutez et testez ; à la fin, vous les recopiez dans ce fichier unique.

Le format est imposé et dépouillé automatiquement. Il est décrit intégralement ci-dessous, et la même page figurera en tête du sujet le jour de l’évaluation. Entraînez-vous à le respecter dès les examens blancs : c’est gratuit, et cela vous évitera de perdre des points bêtement.

4.2 Comment rendre votre copie

AvertissementAttention

Lisez cette page avant de commencer, et réservez-vous 15 minutes à la fin pour le rendu. Les copies sont dépouillées automatiquement : ce qui n’est pas au bon endroit n’est pas corrigé.

1. Un seul fichier texte, et rien d’autre

Vous rendez un unique fichier texte, nommé exactement :

BGD701_EVAL1_NOM_Prenom.txt
  • NOM : votre nom de famille en majuscules, sans accent, sans espace, sans tiret (SEGUINEAU, pas Séguineau de Préval).
  • Prenom : votre prénom, initiale en majuscule, sans accent (Andre, pas André).
  • Exemple : BGD701_EVAL1_DUPONT_Marie.txt
  • Ce fichier contient tout : les réponses de la partie 1 et l’intégralité de votre code de la partie 2.
  • C’est un fichier texte brut. Pas de Word, pas de PDF, pas d’archive, pas de dossier. Utilisez votre éditeur de code.
  • Rien d’autre n’est ramassé : ni vos fichiers .py, ni vos dossiers de travail.

2. Comment travailler, puis rendre

  1. Pendant l’épreuve, travaillez normalement, dans de vrais fichiers .py séparés, que vous exécutez et testez. C’est la seule façon de vérifier que votre code fonctionne.
  2. À la fin, recopiez le contenu de chaque fichier dans le fichier de rendu, sous la balise correspondante.
ImportantÀ retenir

Ne repoussez pas cette recopie à la dernière minute. Dès qu’un fichier fonctionne, collez-le tout de suite dans le fichier de rendu : vous ne risquez plus de le perdre, et vous n’aurez qu’à le remplacer si vous l’améliorez ensuite.

3. Le squelette à recopier

Créez le fichier et recopiez-y exactement ce squelette, puis écrivez vos réponses sous chaque balise.

BGD701_EVAL1_NOM_Prenom.txt
#=== IDENTITE ===
NOM :
Prenom :
Login :

#=== Q1 ===

#=== Q2 ===

#=== Q3 ===

#=== Q4 ===

#=== Q5 ===

#=== Q6 ===

#=== Q7 ===

#=== FICHIER nom_du_fichier.py ===

#=== ETAT ===
Ce qui fonctionne :
Ce qui ne fonctionne pas :
Comment lancer mon code :

#=== REMARQUES ===

#=== FIN ===

Les balises FICHIER attendues pour cette évaluation. Une balise par fichier, avec le nom de fichier exact, dans cet ordre :

Balise Contenu
#=== FICHIER protocole.py === la couche de framing
#=== FICHIER serveur_stats.py === le serveur
#=== FICHIER client_stats.py === le client de test

4. Règles sur les balises

  • Une balise s’écrit seule sur sa ligne, sans rien avant le #, sous la forme #=== NOM === (un espace de chaque côté).
  • Une réponse commence juste après sa balise et se termine à la balise suivante. Il n’y a pas de balise de fermeture.
  • N’inventez, ne renommez, ne supprimez aucune balise, et gardez-les dans l’ordre. Une balise abîmée rend la réponse introuvable, donc non notée.
  • Laissez une balise vide si vous ne traitez pas la question : ne la supprimez pas.
  • #=== FIN === doit être la dernière ligne du fichier.
AvertissementAttention

Votre code ne doit contenir aucune ligne commençant par #=== : elle serait prise pour une balise et couperait votre fichier en deux. Un commentaire Python ordinaire (# ceci est un commentaire) ne pose évidemment aucun problème.

NoteLe code doit être complet

Chaque bloc FICHIER doit contenir le fichier en entier, avec ses import et son indentation d’origine. Il sera extrait tel quel et exécuté : un fichier tronqué ou ré-indenté ne fonctionnera pas. Copiez-collez, ne retapez pas.

5. Conditions matérielles

L’évaluation se déroule sur les machines Linux de l’école, en salle de TP. Tous les raccourcis indiqués ici sont donc ceux de Linux.

AvertissementAttention

Le réseau est coupé pendant l’évaluation. Vous ne pouvez pas utiliser ssh, scp, ni joindre une autre machine. Tout doit fonctionner sur votre seule machine, entre processus, en utilisant l’adresse 127.0.0.1 et plusieurs ports différents.

Les sujets sont conçus pour cela : à aucun moment vous n’avez besoin d’une autre machine ni d’Internet.

  • Choisissez des ports libres entre 40000 et 60000. Vous êtes seul sur votre machine : aucun risque de collision avec un autre candidat.
  • Utilisez plusieurs terminaux : un par serveur, un pour le client.
  • Aucune bibliothèque externe n’est nécessaire ni autorisée : tout est dans la bibliothèque standard de Python.

6. Votre responsabilité

  • Enregistrez très régulièrement (Ctrl+S). Des copies ont déjà été perdues à quelques minutes de la fin.
  • Relisez votre fichier avant de rendre : balises intactes, chaque réponse sous la bonne balise, chaque fichier complet.
  • Vérifiez le nom du fichier : BGD701_EVAL1_NOM_Prenom.txt, sans accent ni espace.
  • Un programme incomplet mais qui démarre rapporte davantage qu’un programme ambitieux qui ne s’exécute pas. Décrivez sous #=== ETAT === ce qui marche et ce qui ne marche pas : c’est pris en compte.
NoteConseil de méthode

Consacrez environ 1 heure à la partie 1 et 2 heures à la partie 2. Un programme qui tourne à moitié rapporte davantage qu’un programme parfait mais inachevé : faites d’abord marcher un cas simple, puis enrichissez.

4.3 Examen blanc no1

NoteCe que cet examen blanc est, et ce qu’il n’est pas

Le sujet qui suit a la forme exacte de l’évaluation : même durée, même structure en deux parties, même barème, même type de questions. Son contenu, en revanche, sera différent le jour de l’évaluation : le protocole à implémenter, les commandes et les questions ne seront pas ceux-ci. Ne cherchez donc pas à apprendre ce corrigé par cœur : entraînez-vous à produire le raisonnement et le code.

Traitez ce sujet en conditions réelles (3 h, sans Internet, réseau coupé, avec ce cours seul) avant la séance 4, et rendez-le au format balisé décrit ci-dessus : c’est aussi cela qui s’apprend. Le corrigé est en annexe, Section 4.4 : ne le consultez qu’après.

Partie 1 — Questions (10 points)

Question 1 — Le déroulé d’une connexion 1,5 pt

On donne les six appels suivants, dans le désordre :

listen   recv   bind   accept   socket   connect

  1. Donnez l’ordre correct des appels côté serveur, et indiquez lequel n’est pas utilisé par le serveur.

  2. Donnez l’ordre correct côté client.

  3. accept() renvoie deux valeurs. Lesquelles ? À quoi sert chacune ? Que se passerait-il si l’on faisait recv sur le socket d’écoute au lieu du socket renvoyé ?

Question 2 — Octets et texte 1,5 pt

Soit le code client suivant :

client.sendall("Bonjour")
reponse = client.recv(1024)
print("Recu : " + reponse)
  1. Ce code contient deux erreurs de même nature. Lesquelles ? Donnez le message d’erreur exact que Python affiche pour la première.

  2. Corrigez le code.

  3. Le serveur envoie la chaîne "café". Combien d’octets circulent sur le réseau ? Justifiez.

Question 3 — Le flux d’octets 2 pts

Un serveur exécute :

connexion.sendall(b"ALPHA")
connexion.sendall(b"BETA")
connexion.sendall(b"GAMMA")

Le client exécute print(client.recv(1024)) trois fois de suite.

  1. Un étudiant affirme : « le client affichera b'ALPHA', b'BETA', b'GAMMA' ». Est-ce garanti ? Justifiez en une phrase précise sur ce que TCP garantit et ne garantit pas.

  2. Donnez deux affichages différents, tous deux possibles.

  3. Le client fait maintenant client.recv(3). Combien d’octets au maximum peut-il recevoir ? Au minimum ?

  4. Citez les deux techniques permettant de retrouver les frontières entre messages, et donnez pour chacune un inconvénient.

Question 4 — Une boucle qui tourne mal 1,5 pt

tout = b""
while True:
    morceau = connexion.recv(1024)
    tout += morceau
    if len(tout) >= 100:
        break
print(tout.decode())
  1. Le client se connecte, envoie 20 octets, puis ferme la connexion. Décrivez précisément le comportement du serveur.

  2. Quelle est la valeur de morceau dans cette situation, et que signifie-t-elle ?

  3. Corrigez la boucle.

Question 5 — Concurrence 1,5 pt

total = 0

def servir(connexion):
    global total
    valeur = int(recevoir_message(connexion)["valeur"])
    total = total + valeur
    connexion.close()

Trente clients envoient chacun la valeur 1, simultanément.

  1. Quelle valeur finale attend-on pour total ? Quelle valeur peut-on réellement obtenir ? Comment s’appelle ce phénomène ?

  2. Expliquez en détaillant les étapes élémentaires de total = total + valeur.

  3. Corrigez le code.

  4. On remplace les threads par un serveur itératif (sans thread). Le bug disparaît-il ? Quel nouveau problème apparaît ?

Question 6 — Timeout et pannes 1 pt

Un programme interroge 200 machines avec un ThreadPoolExecutor(max_workers=100). Cinq machines sont éteintes. Aucun settimeout n’a été posé.

  1. Que se passe-t-il ? Le programme finit-il par se terminer ?

  2. Où placer le settimeout exactement, et pourquoi avant le connect ?

  3. On choisit un timeout de 0,05 s. Quel est le risque ?

Question 7 — Comprendre un protocole 1 pt

Un serveur reçoit les octets suivants :

00 00 00 0B 7B 22 74 79 70 65 22 3A 22 50 49 4E 47 22 7D

Sachant que le protocole utilisé est celui de protocole.py :

  1. Quelle est la taille annoncée de la charge utile ?

  2. Combien d’octets le serveur doit-il lire après l’en-tête ?

  3. Bonus : les octets 7B 22 74 79 70 65 22 3A 22 50 49 4E 47 22 7D correspondent à un texte ASCII. Reconstituez-le (7B = {, 22 = ", 3A = :, 7D = }).

  4. Bonus : comparez le nombre d’octets annoncé par l’en-tête au nombre d’octets réellement fournis. Que va-t-il se passer côté serveur ?

Partie 2 — Programmation (10 points)

Critères d’évaluation

Critère Pts Attendu
Framing 3 Longueur préfixée "!I", recevoir_exactement correcte, dans les deux sens
Commandes 2 PING, STATS, QUIT conformes à la spécification
Concurrence 2 Plusieurs clients simultanés, plusieurs requêtes par connexion
Robustesse 2 Timeout, aucune requête ne fait tomber le serveur, connexions fermées
Client de test 1 Interroge plusieurs serveurs en parallèle, tolère un serveur absent

Un service de statistiques distribué

Vous devez écrire trois fichiers, à recopier ensuite dans votre fichier de rendu sous les balises #=== FICHIER … ===.

1. protocole.py

Le module de framing vu en cours, avec les trois fonctions envoyer_message(sock, objet), recevoir_exactement(sock, n) et recevoir_message(sock). Messages JSON, longueur préfixée sur 4 octets en "!I".

2. serveur_stats.py

Un serveur concurrent qui écoute sur un port de votre choix et répond aux requêtes suivantes :

Requête Réponse
{"type": "PING"} {"type": "PONG"}
{"type": "STATS", "nombres": [...]} {"type": "OK", "n": ..., "min": ..., "max": ..., "moyenne": ...}
{"type": "QUIT"} Ferme proprement la connexion

Contraintes :

  • plusieurs clients doivent être servis simultanément ;
  • une connexion peut porter plusieurs requêtes successives ;
  • toute requête invalide (type inconnu, champ manquant, liste vide, valeur non numérique) donne {"type": "ERREUR", "message": ...} ;
  • le serveur ne doit jamais s’arrêter à cause d’un client.

3. client_stats.py

Un client qui interroge en parallèle une liste de serveurs donnée dans un fichier serveurs.txt (format 127.0.0.1:port, un par ligne), avec un timeout de 3 secondes.

Pour chaque serveur, il envoie la même liste de nombres et récupère les statistiques. Il affiche ensuite un tableau, puis la ligne "k/n serveurs ont repondu". Un serveur injoignable ne doit pas interrompre le programme.

Test attendu. Lancez trois serveurs sur trois ports différents, mettez-les dans serveurs.txt, ajoutez-y une entrée volontairement fermée (127.0.0.1:45999, où rien n’écoute), et vérifiez que votre client affiche bien 3/4 serveurs ont repondu.

4.4 Corrigés

Partie 1

a) 0,5 pt Serveur : socket →\rightarrow bind →\rightarrow listen →\rightarrow accept →\rightarrow recv. connect n’est pas utilisé par le serveur.

b) 0,25 pt Client : socket →\rightarrow connect →\rightarrow recv (et sendall).

c) 0,75 pt accept() renvoie (connexion, adresse). La connexion est un nouveau socket dédié au dialogue avec ce client ; l’adresse est le couple (IP, port) du client, utile pour la journalisation. Faire recv sur le socket d’écoute est une erreur : ce socket ne transporte aucune donnée applicative, il ne sert qu’à accepter des connexions ; Python lève une OSError.

a) 0,75 pt Les deux erreurs sont des confusions entre str et bytes : sendall exige des bytes, et "Recu : " + reponse concène un str avec des bytes. Message pour la première : TypeError: a bytes-like object is required, not 'str'.

b) 0,25 pt client.sendall("Bonjour".encode()) et print("Recu : " + reponse.decode()).

c) 0,5 pt "café" fait 4 caractères mais 5 octets en UTF-8 : le é est codé sur deux octets. Le nombre de caractères et le nombre d’octets ne coïncident pas dès qu’on sort de l’ASCII — une raison de plus pour préfixer par la longueur en octets.

a) 0,5 pt Non, ce n’est pas garanti. TCP garantit que les octets arrivent tous et dans l’ordre ; il ne préserve pas les frontières entre les appels à sendall, car il transporte un flux d’octets, pas des messages.

b) 0,5 pt Deux affichages possibles parmi d’autres : b'ALPHABETAGAMMA', b'', b'' (coalescence totale) ; ou b'ALPHABETA', b'GAMMA', b'' (coalescence partielle).

c) 0,5 pt Au maximum 3 octets, au minimum 1 octet (recv ne renvoie jamais 0 octet sauf fermeture, où il renvoie b"").

d) 0,5 pt Délimiteur : simple et lisible, mais impose d’échapper le délimiteur s’il apparaît dans les données, et convient mal au binaire. Longueur préfixée : transparente au contenu, mais illisible à l’œil nu et il faut se prémunir d’une taille annoncée aberrante.

a) 0,5 pt Le serveur reçoit 20 octets, puis recv renvoie b"" indéfiniment. tout n’atteint jamais 100 octets, donc la boucle ne s’arrête jamais : boucle infinie consommant 100 % d’un cœur.

b) 0,5 pt morceau vaut b"", ce qui signifie que le pair a fermé la connexion — et non qu’il n’y a pas de données pour l’instant.

c) 0,5 pt

tout = b""
while len(tout) < 100:
    morceau = connexion.recv(1024)
    if not morceau:
        break            # ou lever une exception
    tout += morceau

a) 0,25 pt On attend 30 ; on peut obtenir n’importe quelle valeur entre 1 et 30. C’est une condition de course (race condition).

b) 0,5 pt total = total + valeur se décompose en : lire total, calculer la somme, écrire total. Si deux threads lisent 10 en même temps, tous deux écrivent 11 au lieu de 12 : un incrément est perdu.

c) 0,5 pt

verrou = threading.Lock()
def servir(connexion):
    global total
    valeur = int(recevoir_message(connexion)["valeur"])
    with verrou:
        total += valeur
    connexion.close()

d) 0,25 pt Oui, le bug disparaît : un seul thread, donc aucune concurrence. Mais le serveur redevient itératif : les 30 clients sont servis l’un après l’autre, et le débit s’effondre. On a échangé un bug de correction contre un problème de performance.

a) 0,4 pt Les threads qui tentent de joindre les 5 machines éteintes restent bloqués dans connect pendant le délai par défaut du système (souvent 1 à 2 minutes). Le programme finit par se terminer, mais très tard, et en occupant 5 threads du pool pour rien.

b) 0,3 pt Juste après socket.socket(...), donc avant connect : le timeout doit être armé avant l’opération que l’on veut borner, et connect est précisément l’appel le plus long en cas de machine injoignable.

c) 0,3 pt Avec 0,05 s, on risque de déclarer injoignables des machines parfaitement vivantes mais momentanément lentes ou distantes : ce sont des faux positifs. C’est le dilemme du délai, sur lequel nous reviendrons plus tard dans le cours.

a) 0,25 pt 00 00 00 0B en hexadécimal vaut 11 en décimal.

b) 0,25 pt 11 octets, puisque c’est ce qu’annonce l’en-tête. Le serveur obéit à l’en-tête, pas à ce qu’il devine du contenu.

c) 0,25 pt {"type":"PING"}.

d) 0,25 pt Le texte fait 15 octets, alors que l’en-tête en annonce 11 : l’émetteur a menti, ou s’est trompé.

Le serveur lit donc 11 octets et obtient {"type":"P, sur lequel json.loads échoue — d’où l’importance du try/except autour de la désérialisation. Pire : les 4 octets restants (ING"}) demeurent dans le flux et seront interprétés comme le début de l’en-tête du message suivant. La connexion est désynchronisée : tout ce qui suit est faux.

C’est la raison pour laquelle, face à un message incohérent, un serveur sérieux ne tente pas de se rattraper : il ferme la connexion.

Partie 2

C’est exactement le module de la séance 3, sans une ligne de différence. Si vous avez dû le réinventer le jour de l’épreuve, c’est le signe qu’il fallait le savoir par cœur.

protocole.py
"""Envoi/reception de messages JSON avec longueur prefixee."""
import json
import struct

TAILLE_ENTETE = 4                   # "!I" = 4 octets
TAILLE_MAX = 100 * 1024 * 1024      # garde-fou : 100 Mo

def envoyer_message(sock, objet):
    charge = json.dumps(objet).encode("utf-8")
    sock.sendall(struct.pack("!I", len(charge)) + charge)

def recevoir_exactement(sock, nb_octets):
    morceaux = b""
    while len(morceaux) < nb_octets:
        morceau = sock.recv(nb_octets - len(morceaux))
        if not morceau:
            raise ConnectionError("Connexion fermee prematurement")
        morceaux += morceau
    return morceaux

def recevoir_message(sock):
    entete = recevoir_exactement(sock, TAILLE_ENTETE)
    (taille,) = struct.unpack("!I", entete)
    if taille > TAILLE_MAX:
        raise ValueError(f"Message trop grand : {taille} octets")
    charge = recevoir_exactement(sock, taille)
    return json.loads(charge.decode("utf-8"))

Ce qui coûte des points critère « Framing », 3 pts : un recv qui ne boucle pas, un b"" non testé, l’oubli du ! dans struct.pack, ou l’absence de garde-fou sur la taille annoncée.

serveur_stats.py
import socket, threading, sys
from protocole import envoyer_message, recevoir_message

def traiter(requete):
    t = requete.get("type")
    if t == "PING":
        return {"type": "PONG"}
    if t == "STATS":
        nombres = requete.get("nombres")
        if not isinstance(nombres, list) or not nombres:
            return {"type": "ERREUR",
                    "message": "liste absente ou vide"}
        try:
            valeurs = [float(x) for x in nombres]
        except (TypeError, ValueError):
            return {"type": "ERREUR",
                    "message": "valeurs non numeriques"}
        return {"type": "OK",
                "n": len(valeurs),
                "min": min(valeurs),
                "max": max(valeurs),
                "moyenne": sum(valeurs) / len(valeurs)}
    return {"type": "ERREUR", "message": f"type inconnu : {t}"}

def servir(connexion, adresse):
    connexion.settimeout(30.0)
    try:
        while True:
            requete = recevoir_message(connexion)
            if requete.get("type") == "QUIT":
                break
            envoyer_message(connexion, traiter(requete))
    except (ConnectionError, ValueError, OSError):
        pass
    finally:
        connexion.close()

if __name__ == "__main__":
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 50007
    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(("127.0.0.1", port))
    srv.listen(64)
    print(f"Serveur stats sur le port {port}")
    while True:
        c, a = srv.accept()
        threading.Thread(target=servir, args=(c, a),
                         daemon=True).start()
client_stats.py
import socket, random
from concurrent.futures import ThreadPoolExecutor
from protocole import envoyer_message, recevoir_message

NOMBRES = [random.randint(1, 1000) for _ in range(100)]

def interroger(entree):
    machine, port = entree.split(":")
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.settimeout(3.0)
        s.connect((machine, int(port)))
        envoyer_message(s, {"type": "STATS", "nombres": NOMBRES})
        reponse = recevoir_message(s)
        envoyer_message(s, {"type": "QUIT"})
        s.close()
        if reponse.get("type") == "OK":
            return (entree, reponse)
    except (OSError, ValueError, ConnectionError):
        pass
    return (entree, None)

serveurs = [l.strip() for l in open("serveurs.txt") if l.strip()]
with ThreadPoolExecutor(max_workers=20) as pool:
    resultats = list(pool.map(interroger, serveurs))

ok = 0
print(f"{'serveur':30s} {'n':>5s} {'min':>8s} "
      f"{'max':>8s} {'moyenne':>10s}")
for entree, r in resultats:
    if r is None:
        print(f"{entree:30s} {'INJOIGNABLE':>33s}")
    else:
        ok += 1
        print(f"{entree:30s} {r['n']:5d} {r['min']:8.1f} "
              f"{r['max']:8.1f} {r['moyenne']:10.2f}")

print(f"\n{ok}/{len(serveurs)} serveurs ont repondu")