minitalk
// Un protocole de communication client/serveur tissé à partir de signaux UNIX — un bit à la fois.
Recoder minitalk, c'est apprendre à faire parler deux processus
sans aucune fonction réseau ni aucun pipe — uniquement deux
signaux, SIGUSR1 et SIGUSR2, qui transportent
chacun un bit d'information. Ce guide décortique l'implémentation fichier par
fichier, du handler signal() du mandatory jusqu'au système d'acks
sigaction du bonus, en passant par la race condition qui corrompt
silencieusement les messages longs.
La communication entre le client et le serveur ne peut se faire que via
SIGUSR1 et SIGUSR2. Aucun autre signal,
aucune IPC, aucun fichier partagé. Le serveur doit afficher son PID au lancement, recevoir la
chaîne envoyée par le client, l'afficher, puis rester disponible pour d'autres clients sans
redémarrage. La norme 42 s'applique, et une seule variable globale par programme est tolérée.
— Manuel de terrain YoRHa, module 042
Le projet
minitalk est un projet d'introduction aux signaux UNIX.
Le but : faire communiquer deux programmes — un serveur et un client — en utilisant uniquement
les signaux SIGUSR1 et SIGUSR2. Pas de
pipe, pas de socket, pas de fichier
partagé. Chaque signal transporte un bit d'information, et c'est en alignant ces bits que le
client transmet une chaîne de caractères au serveur.
Le projet paraît trivial au premier abord : on envoie des 0 et des 1, le serveur les
reconstruit. Mais très vite, on se heurte à la réalité des signaux UNIX : ils sont
asynchrones, ils peuvent être coalescents (deux signaux
identiques envoyés rapidement ne comptent qu'une fois), ils interrompent le flot d'exécution
à des moments imprévisibles, et le handler n'a accès qu'à un seul argument entier. Tout le
défi de minitalk est de bâtir un protocole robuste par-dessus
cette couche capricieuse.
Exigences du sujet
Le sujet officiel impose les contraintes suivantes :
| Critère | Exigence | Détail |
|---|---|---|
| Communication | Signaux uniquement | SIGUSR1 et SIGUSR2 — aucun autre |
| Lancement | Server d'abord | Affiche Server PID: <pid> au démarrage |
| Client | Format strict | ./client <server_pid> <string> |
| Performance | Rapide | 1 seconde pour 100 caractères = colossal |
| Multi-client | Sans restart | Plusieurs clients à la suite sans relancer le serveur |
| Norme | Norminette | 0 erreur, 0 warning -Wall -Wextra -Werror |
| Globales | Max 1 par programme | Ou aucune — préféré |
| Makefile | Rules obligatoires | all, clean, fclean, re, bonus |
| Bonus | Acks + Unicode | sigaction autorisé uniquement pour le bonus |
La métaphore YoRHa
Pour rendre le projet plus immersif, ce guide adopte l'univers NieR Automata.
Le serveur est le Bunker YoRHa : il écoute en permanence les transmissions
entrantes, affiche les rapports reçus, et reste opérationnel 24/7. Le client est un
Pod (unité tactique) qui envoie un message au Bunker via deux canaux radio
only : SIGUSR1 (bit 0) et SIGUSR2 (bit 1).
Le bonus introduit les acks — accusés de réception que le Bunker renvoie au
Pod après chaque bit, à la manière des rapports de validation que 2B transmet au Commandant
après chaque ordre exécuté.
signal() sur SIGUSR1/SIGUSR2,
affiche son PID, puis entre dans un while (1) pause().
Le handler reçoit un signal à la fois, accumule les bits, reconstruit chaque octet
MSB-first, et affiche le caractère quand l'octet est complet.
usleep
entre chaque bit évite que les signaux ne s'écrasent mutuellement dans la file d'attente
du noyau.
Avant d'écrire la moindre ligne de code, lisez la page man signal
et man sigaction en entier. La section NOTES de
signal(2) explique pourquoi signal() a
un comportement indéfini sur la persistance du handler entre les Unix — c'est
la raison pour laquelle le bonus exige sigaction.
Théorie des signaux UNIX
Un signal est une interruption logicielle envoyée à un processus. C'est l'un
des plus anciens mécanismes de communication inter-processus (IPC) d'UNIX — antérieur aux
pipes, aux sockets, aux files de messages. Un signal ne transporte qu'une seule information :
un numéro (1 pour SIGHUP, 9 pour
SIGKILL, 10 pour SIGUSR1, 12 pour
SIGUSR2, etc.). Pas de payload, pas de données attachées, juste
un numéro qui dit « ce signal-là vient de se produire ».
Quand un signal arrive, le noyau interrompt le processus en cours, sauvegarde
son contexte, et appelle le handler enregistré pour ce signal. Une fois le handler
terminé, le contexte est restauré et le processus reprend comme si de rien n'était — sauf si
le handler a modifié des variables volatile sig_atomic_t, auquel
cas ces modifications sont visibles.
signal() vs sigaction()
Il existe deux API pour enregistrer un handler : l'ancienne signal()
et la moderne sigaction(). Le sujet autorise
signal() pour le mandatory et réserve
sigaction() au bonus. Pourquoi cette restriction ? Parce que
signal() a un comportement historicement variable
entre les Unix, tandis que sigaction() est explicite et portable.
signal(SIGUSR1, handler). Le handler reçoit
uniquement le numéro du signal. Inconvénients : comportement de
réinstallation du handler non garanti (sur certains Unix anciens, le handler se
désenregistre après le premier signal), pas de masquage pendant l'exécution du handler,
pas d'accès aux métadonnées (PID émetteur, etc.).
struct sigaction avec
sa_sigaction (handler étendu),
sa_mask (signaux à bloquer pendant le handler), et
sa_flags (SA_SIGINFO pour
récupérer siginfo_t qui contient
si_pid). Comportement portable et explicite.
/* signal() — mandatory, simple mais limité */ void handler(int sig) { /* sig = SIGUSR1 ou SIGUSR2, rien d'autre */ /* pas d'info sur l'émetteur */ } signal(SIGUSR1, handler); signal(SIGUSR2, handler); /* sigaction() — bonus, puissant et portable */ void handler_ext(int sig, siginfo_t *info, void *ctx) { /* info->si_pid = PID de l'émetteur */ /* info->si_signo = numéro du signal */ /* info->si_value = donnée accompagnante (si SA_SIGINFO) */ } struct sigaction sa; sa.sa_sigaction = handler_ext; sigemptyset(&sa.sa_mask); sigaddset(&sa.sa_mask, SIGUSR1); /* bloque SIGUSR1 */ sigaddset(&sa.sa_mask, SIGUSR2); /* bloque SIGUSR2 pendant le handler */ sa.sa_flags = SA_SIGINFO; /* active siginfo_t */ sigaction(SIGUSR1, &sa, NULL); sigaction(SIGUSR2, &sa, NULL);
Sémantique reliable vs unreliable
Historiquement, signal() avait une sémantique
unreliable : après que le handler avait été appelé, le signal était
réinstallé à SIG_DFL (terminaison du processus). Il fallait donc
réenregistrer le handler à l'intérieur du handler lui-même, ce qui créait une
fenêtre de temps où un signal pouvait tuer le processus. Les BSD modernes et Linux
implémentent désormais une sémantique reliable par défaut avec
signal(), mais ce n'est pas portable. sigaction
est le seul moyen d'avoir un comportement garanti.
La coalescence des signaux
C'est le piège n°1 de minitalk. Les signaux UNIX ne sont
pas mis en file d'attente (sauf signaux temps-réel SIGRTMIN+
qu'on n'a pas le droit d'utiliser ici). Si deux SIGUSR1 sont envoyés
au même processus avant qu'il ait eu le temps de traiter le premier, le noyau ne délivrera
qu'un seul SIGUSR1 au handler. Le second est
perdu — silencieusement.
La solution est d'insérer un usleep() entre chaque
kill() côté client. La valeur typique est 100µs : assez longue
pour que le serveur ait le temps de traiter le signal, assez courte pour rester rapide
(100 caractères en ~80ms = 1.25s pour 1000 caractères). Mais cette approche a une limite :
pour des messages très longs (plusieurs milliers de caractères), la latence du scheduler peut
provoquer des coalescences sporadiques. Le bonus avec acks résout ce problème en
synchronisant explicitement chaque bit.
La coalescence est le Black Scrawl de minitalk : une corruption
silencieuse qui ronge les données sans crash ni message d'erreur. Le serveur affiche des
caractères erronés, perd des bits, ou pire, désynchronise son bit_index
et corrompt tous les caractères suivants. Sans système d'acks, vous ne pouvez que prier que
le scheduler soit assez rapide. Avec les acks, vous êtes maître du timing.
Le protocole bit-à-bit
Puisqu'un signal ne transporte qu'un seul bit d'information (présent/absent, ou dans notre cas
SIGUSR1/SIGUSR2), il faut un protocole
pour reconstruire un octet à partir de 8 signaux successifs. Notre protocole est volontairement
simple : MSB-first (bit de poids fort d'abord), 8 bits par
caractère, terminateur NUL ('\0') pour
marquer la fin du message.
Encodage MSB-first
Pour chaque caractère de la chaîne, on extrait les 8 bits en partant du bit 7 (le plus
significatif) jusqu'au bit 0 (le moins significatif). Chaque bit est envoyé comme un signal :
SIGUSR1 pour un 0, SIGUSR2 pour un 1.
Le serveur reçoit les signaux dans l'ordre, reconstruit l'octet en positionnant chaque bit
reçu à sa place, et affiche le caractère quand l'octet est complet.
/* Envoi du caractère 'A' = 0x41 = 0b01000001 */ int mt_send_char(int pid, unsigned char c) { int i = 7; while (i >= 0) { int bit = (c >> i) & 1; /* MSB first */ int sig = (bit == 1) ? SIGUSR2 : SIGUSR1; kill(pid, sig); usleep(100); /* anti-coalescence */ i--; } return (0); } /* 'A' = 0x41 → bits envoyés : 0,1,0,0,0,0,0,1 signaux envoyés : USR1,USR2,USR1,USR1,USR1,USR1,USR1,USR2 */
Timeline client→server
Le terminateur NUL
Comment le serveur sait-il que le message est fini ? Deux approches : envoyer la taille
d'abord (mais cela nécessite un protocole d'en-tête), ou utiliser un caractère
sentinelle. Nous choisissons la seconde : on envoie toujours un octet
'\0' (valeur 0) après le dernier caractère du message. Côté
serveur, quand l'octet reconstruit vaut 0, on sait que le message est terminé et on affiche
un retour à la ligne.
Envoyer la taille nécessiterait un protocole d'en-tête (4 ou 8 octets pour le count),
suivi du payload. Cela complique le code serveur (gestion de deux phases : lecture taille,
puis lecture payload). Le terminateur NUL est plus simple : le serveur traite tous les octets
de la même façon, et distingue uniquement le cas char == '\0' pour
savoir qu'il faut afficher un newline. Inconvénient : on ne peut pas envoyer
une chaîne contenant un NUL — mais ce n'est pas une exigence du sujet.
Architecture du code
Le projet suit une structure classique 42 : une libft minimaliste
(seulement les fonctions réellement utilisées), un dossier srcs
pour le mandatory, un dossier srcs_bonus pour le bonus, et deux
headers dans includes. Le Makefile orchestre tout : il compile
d'abord la libft, puis les sources du projet, puis linke l'exécutable.
Dépendances entre fichiers
La séparation client.c / mt_utils.c
permet de mutualiser mt_validate_pid,
mt_send_char et mt_send_string entre le
client mandatory et — conceptuellement — tout autre appelant. Dans le bonus, on duplique
légèrement (mt_utils_bonus.c) pour respecter la règle "bonus dans
des fichiers _bonus.{c,h}".
La règle 42 veut que les fichiers bonus soient suffixés _bonus.
On applique cela à server_bonus.c,
client_bonus.c, mt_utils_bonus.c,
mt_send_bonus.c, minitalk_bonus.h. Les
binaires produits sont server_bonus et
client_bonus. La norminette vérifie ces fichiers en plus du
mandatory lors de l'évaluation bonus.
server.c décortiqué
Le serveur est la pièce maîtresse du mandatory. Il doit : (1) enregistrer un handler pour
SIGUSR1 et SIGUSR2, (2) afficher son
PID au lancement, (3) entrer dans une boucle infinie qui attend les signaux, (4) reconstruire
les caractères à partir des bits reçus, et (5) afficher la chaîne complète quand il rencontre
le terminateur NUL. Tout cela en moins de 25 lignes par fonction (norme 42) et avec zéro
variable globale.
Le handler mt_handler
static void mt_handler(int sig) { static unsigned char current_char = 0; static int bit_index = 7; unsigned char to_write; if (sig == SIGUSR2) current_char |= (1 << bit_index); bit_index--; if (bit_index < 0) { to_write = current_char; current_char = 0; bit_index = 7; if (to_write == '\0') write(1, "\n", 1); else write(1, &to_write, 1); } }
Analyse ligne par ligne
Les variables current_char et bit_index
sont déclarées static à l'intérieur de la fonction. C'est crucial :
sans static, elles seraient réinitialisées à chaque appel du
handler, et le serveur ne pourrait jamais accumuler les 8 bits d'un caractère. Grâce à
static, elles persistent entre les appels — c'est l'équivalent
d'une variable globale, mais sans enfreindre la règle "max 1 globale par programme" et sans
polluer l'espace de noms global.
bit_index démarre à 7 (MSB) et décrémente à chaque signal. Quand
on reçoit SIGUSR2, on met le bit correspondant à 1 dans
current_char avec current_char |= (1 << bit_index).
Si on reçoit SIGUSR1, on ne fait rien — le bit reste à 0 (valeur
initiale de current_char).
Quand bit_index passe en dessous de 0, l'octet est complet. On le
sauvegarde dans to_write, on réinitialise
current_char et bit_index pour le
prochain caractère, puis on écrit. Si le caractère est '\0'
(terminateur), on envoie un newline ; sinon, on écrit le caractère lui-même.
Remarquez l'ordre : on sauvegarde to_write = current_char,
puis on reset current_char = 0 et bit_index = 7,
puis on appelle write(). Pourquoi ? Parce que
write() est un appel système qui peut bloquer (si le pipe stdout
est plein), et pendant ce blocage un nouveau signal peut arriver. Si on n'avait pas reset
avant, le handler ré-entrant corromprait current_char et
bit_index en plein milieu du traitement. Voir
section 08 — Race condition.
Le main
int main(void) { signal(SIGUSR1, mt_handler); signal(SIGUSR2, mt_handler); ft_putstr_fd("Server PID: ", 1); ft_putnbr_fd(getpid(), 1); ft_putstr_fd("\n", 1); while (1) pause(); return (0); }
Le main est trivial : on enregistre le handler pour les deux signaux, on affiche le PID, puis
on entre dans une boucle infinie de pause().
pause() endort le processus jusqu'à ce qu'un signal arrive —
c'est plus efficace qu'un while(1); qui boufferait 100% CPU.
Quand un signal arrive, le handler s'exécute, puis pause()
retourne (le signal a interrompu l'attente), et on boucle pour attendre le suivant.
sleep(1) endort le processus pendant 1 seconde — c'est
inutilement long. pause() est conçu exactement pour ce cas
d'usage : attendre indéfiniment le prochain signal sans consommation CPU. Le handler sera
appelé immédiatement quand le signal arrive, sans attendre la fin du
sleep.
Pourquoi écrire caractère par caractère ?
On pourrait être tenté de bufferiser toute la chaîne dans un tableau statique, et de tout
écrire d'un coup quand on reçoit le terminateur NUL. C'est en effet plus efficace pour les
longs messages (un seul appel à write au lieu de N). Mais cela
pose deux problèmes : (1) il faut gérer un buffer de taille dynamique ou fixe (avec risque
de débordement), (2) le handler devient plus long et plus complexe. La norme 42 limite les
fonctions à 25 lignes, donc on doit faire des compromis.
Notre choix : écrire caractère par caractère. C'est plus simple, plus sûr (pas de buffer à
gérer), et la perte de performance est acceptable pour le mandatory (un appel
write par caractère, soit 100 appels pour 100 caractères — le
noyau gère ça très bien). Le bonus pourrait introduire un buffer si on voulait optimiser
pour les très longs messages, mais ce n'est pas exigé par le sujet.
client.c décortiqué
Le client est plus simple que le serveur : il valide ses arguments, vérifie que le serveur
existe, puis envoie la chaîne bit par bit. Toute la logique d'envoi est dans
mt_utils.c (voir section 07),
le main du client n'est que de la glue.
int main(int argc, char **argv) { int server_pid; if (argc != 3) { ft_putstr_fd("ERROR: usage: ./client <server_pid> <message>\n", 1); return (1); } server_pid = mt_validate_pid(argv[1]); if (server_pid == 0) { ft_putstr_fd("ERROR: invalid server PID\n", 1); return (1); } if (kill(server_pid, 0) != 0) { ft_putstr_fd("ERROR: server process not found\n", 1); return (1); } if (mt_send_string(server_pid, argv[2]) != 0) { ft_putstr_fd("ERROR: failed to send message\n", 1); return (1); } return (0); }
Les 4 niveaux de validation
argc == 3 : le nom du programme, le
PID serveur, et le message. Toute autre valeur → erreur. Le testeur
sailingteam4 vérifie que ./client 1234
(argc=2) et ./client 554 5773 44130 (argc=4) produisent une
erreur.
"0", "-1",
"hehe", "2147483648" (overflow),
"000" (leading zeros).
kill(pid, 0) n'envoie aucun signal — il vérifie juste si le
processus existe et si on a le droit de lui envoyer des signaux. Retourne -1 si le
processus n'existe pas. Cela évite d'attendre le premier kill
réel pour découvrir que le serveur est mort.
kill échoue en cours de
route (par exemple, le serveur crashe), on sort en erreur. Dans le mandatory, il n'y a
pas de retry — c'est une limitation acceptée.
Le testeur sailingteam4 lit stdout
et cherche le mot "ERROR" en majuscules. Donc nos messages d'erreur doivent : (1) aller sur
stdout (fd 1), pas stderr, et (2)
contenir "ERROR" en majuscules. C'est une convention de fait imposée par le testeur, pas par
le sujet — mais la respecter garantit le succès des tests de parsing.
Quand le client se termine-t-il ?
Dans le mandatory, le client se termine dès que mt_send_string
retourne — c'est-à-dire dès que le dernier signal (celui du terminateur NUL) a été envoyé.
Il n'attend pas que le serveur ait effectivement traité le message. C'est un comportement
acceptable pour le sujet, mais cela pose un problème subtil : si le client se termine
immédiatement après le dernier kill, le serveur peut ne pas avoir
eu le temps de traiter tous les signaux en file d'attente. En pratique, sur Linux, les
signaux en file sont délivrés même après la mort de l'émetteur, donc ça marche. Mais sur
d'autres Unix, ce n'est pas garanti.
Le bonus résout ce problème en attendant l'ack du serveur pour chaque bit — y compris le dernier. Le client ne se termine qu'après avoir reçu l'ack du terminateur NUL, ce qui garantit que le serveur a tout traité.
mt_utils.c : validation et envoi
mt_utils.c contient les fonctions utilitaires partagées :
mt_validate_pid (validation stricte du PID serveur),
mt_send_char (envoi d'un octet bit par bit), et
mt_send_string (envoi d'une chaîne + terminateur NUL).
mt_validate_pid
int mt_validate_pid(const char *str) { long pid; int i; if (str == NULL || str[0] == '\0') return (0); i = 0; while (str[i] != '\0') { if (str[i] < '0' || str[i] > '9') return (0); i++; } pid = ft_atol(str); if (pid < 1 || pid > 2147483647) return (0); return ((int)pid); }
La fonction effectue trois vérifications : (1) la chaîne n'est pas NULL ou vide, (2) tous les
caractères sont des digits (rejette les signes - ou
+, les lettres, les leading zeros comme
"000" qui seraient interprétés comme 0 par
ft_atol mais qui ne sont pas un PID valide), et (3) la valeur
convertie est dans [1, INT_MAX].
On utilise ft_atol (qui retourne un long)
plutôt que ft_atoi (qui retourne un int)
pour pouvoir détecter les overflow : si l'utilisateur passe
"2147483648" (INT_MAX + 1), ft_atoi
ferait un overflow silencieux et retournerait une valeur négative, tandis que
ft_atol retourne correctement 2147483648L, qu'on peut alors
comparer à INT_MAX et rejeter.
mt_send_char
int mt_send_char(int pid, unsigned char c) { int i; int bit; int sig; i = 7; while (i >= 0) { bit = (c >> i) & 1; if (bit == BIT_1) sig = SIGUSR2; else sig = SIGUSR1; if (kill(pid, sig) != 0) return (-1); usleep(CLIENT_BIT_DELAY_US); i--; } return (0); }
La boucle parcourt les bits de 7 (MSB) à 0 (LSB). Pour chaque bit, on calcule sa valeur avec
(c >> i) & 1, on choisit le signal correspondant (USR2 pour 1,
USR1 pour 0), on envoie avec kill, puis on attend
CLIENT_BIT_DELAY_US microsecondes (100µs par défaut) avant le bit
suivant. Ce délai est crucial pour éviter la coalescence (voir
section 02).
Si kill retourne -1 (par exemple, le serveur est mort en cours de
route), on sort immédiatement avec -1. Le main du client
intercepte cette erreur et affiche "ERROR: failed to send message".
mt_send_string
int mt_send_string(int pid, const char *str) { size_t i; if (str == NULL) return (-1); i = 0; while (str[i] != '\0') { if (mt_send_char(pid, (unsigned char)str[i]) != 0) return (-1); i++; } return (mt_send_char(pid, '\0')); }
On parcourt la chaîne caractère par caractère, en appelant mt_send_char
pour chacun. Une fois la fin de chaîne atteinte, on envoie un caractère NUL explicite comme
terminateur — c'est ce qui permet au serveur de savoir que le message est fini et d'afficher
un newline. Sans ce terminateur, le serveur resterait dans l'attente du prochain caractère
indéfiniment.
Le cast (unsigned char)str[i] est important : si on passait
directement str[i] (qui est char,
potentiellement signé sur certaines plateformes), les caractères ASCII étendus (accentués,
emoji en UTF-8) auraient leur bit de poids fort interprété comme signe, et le décalage
c >> i ferait un shift arithmétique (remplissage par 1) au lieu
d'un shift logique (remplissage par 0). Résultat : bits erronés, caractères corrompus. Le
cast unsigned char garantit un shift logique propre.
Race condition & Black Scrawl
Le bug le plus insidieux de minitalk n'est pas un crash, pas une
fuite mémoire, pas une erreur de compilation — c'est une race condition
silencieuse qui corrompt les caractères de manière aléatoire pour les messages longs. Cette
section décortique le problème et la solution.
Le problème
Imaginons le handler naïf suivant (qui semble correct au premier abord) :
static void naive_handler(int sig) { static unsigned char current_char = 0; static int bit_index = 7; if (sig == SIGUSR2) current_char |= (1 << bit_index); bit_index--; if (bit_index < 0) { if (current_char == '\0') write(1, "\n", 1); else write(1, ¤t_char, 1); current_char = 0; /* reset APRÈS write — BUG */ bit_index = 7; } }
Ce handler semble correct : on accumule les bits, et quand l'octet est complet on l'écrit,
puis on reset. Mais il y a un piège : write() est un appel
système, et pendant que le processus est en write, le noyau peut
délivrer un nouveau signal. Quand cela arrive, le handler est ré-entrant :
il s'appelle lui-même en plein milieu de son exécution, avec les mêmes variables
static.
Le scénario du désastre
La solution — reset avant write
La solution est simple mais subtile : il faut reset les variables static AVANT
l'appel à write(). Ainsi, si un signal arrive pendant
le write, le handler ré-entrant trouvera des variables propres
(current_char = 0, bit_index = 7) et traitera le nouveau bit correctement comme le premier
bit d'un nouvel octet.
static void mt_handler(int sig) { static unsigned char current_char = 0; static int bit_index = 7; unsigned char to_write; /* variable locale, pas de race */ if (sig == SIGUSR2) current_char |= (1 << bit_index); bit_index--; if (bit_index < 0) { to_write = current_char; /* 1. sauvegarder */ current_char = 0; /* 2. RESET avant write */ bit_index = 7; /* 3. RESET avant write */ if (to_write == '\0') /* 4. write (sûr maintenant) */ write(1, "\n", 1); else write(1, &to_write, 1); } }
Avec cette correction, si un signal arrive pendant le write, le
handler ré-entrant trouve current_char = 0 et
bit_index = 7 — c'est-à-dire l'état initial attendu pour commencer
un nouvel octet. Le bit reçu est correctement placé en position 7, et le traitement continue
normalement. La variable locale to_write n'est pas affectée par
la ré-entrance car elle est allouée sur la stack du handler principal, indépendamment de la
stack du handler ré-entrant.
Ce pattern — sauvegarder l'état mutable dans une variable locale, reset l'état
shared, puis faire l'opération bloquante — est un classic de la programmation
asynchrone par signaux. Il s'applique à tout handler qui appelle des fonctions non
async-signal-safe (write, malloc, etc.). Retenez-le : si votre handler modifie des variables
static et appelle une fonction qui peut bloquer, faites le reset
avant l'appel bloquant.
Bonus : sa_mask pour bloquer les signaux
Le bonus offre une solution encore plus robuste : avec sigaction,
on peut configurer sa_mask pour bloquer
SIGUSR1 et SIGUSR2 pendant l'exécution
du handler. Les signaux reçus pendant le handler ne sont pas perdus — ils sont mis en file
d'attente et délivrés à la fin du handler. Cela élimine complètement la ré-entrance.
struct sigaction sa; sa.sa_sigaction = mt_handler; sigemptyset(&sa.sa_mask); sigaddset(&sa.sa_mask, SIGUSR1); /* bloque SIGUSR1 pendant le handler */ sigaddset(&sa.sa_mask, SIGUSR2); /* bloque SIGUSR2 pendant le handler */ sa.sa_flags = SA_SIGINFO; sigaction(SIGUSR1, &sa, NULL); sigaction(SIGUSR2, &sa, NULL);
Avec sa_mask configuré, le handler n'est jamais ré-entrant —
le noyau bloque les signaux spécifiés jusqu'à la fin du handler. C'est plus sûr que la
solution "reset avant write" car elle élimine aussi les races sur les variables non
sig_atomic_t (par exemple si on avait un buffer dynamique).
Bonus : sigaction & acks
Le bonus de minitalk apporte deux améliorations : (1) le support
Unicode (qui en réalité marche déjà dans le mandatory si on transmet des bytes bruts), et (2)
un système d'accusés de réception où le serveur renvoie un signal au client
après chaque bit reçu. Cette section décrit l'implémentation avec
sigaction.
Pourquoi sigaction est supérieur
sigaction offre trois avantages décisifs sur
signal :
-
Portabilité : comportement garanti identique sur tous les Unix
(POSIX.1-2001).
signala un comportement variable (réinstallation du handler ou non selon l'implémentation). -
Masquage :
sa_maskpermet de bloquer d'autres signaux pendant le handler, éliminant la ré-entrance. -
Métadonnées : avec
SA_SIGINFO, le handler reçoit unsiginfo_t *qui contientsi_pid(PID de l'émetteur) — indispensable pour renvoyer un ack au bon client.
Le handler bonus
static void mt_handler(int sig, siginfo_t *info, void *ctx) { static unsigned char current_char = 0; static int bit_index = 7; pid_t client_pid; (void)ctx; client_pid = info->si_pid; /* PID de l'émetteur */ if (sig == SIGUSR2) current_char |= (1 << bit_index); bit_index--; if (bit_index < 0) { if (current_char == '\0') write(1, "\n", 1); else write(1, ¤t_char, 1); current_char = 0; bit_index = 7; } if (client_pid > 0) kill(client_pid, SIGUSR1); /* ACK au client */ }
Le handler bonus diffère du mandatory sur trois points : (1) il a une signature à 3
arguments (siginfo_t * en plus), (2) il récupère le PID du client
via info->si_pid, et (3) à la fin il envoie un
SIGUSR1 au client comme accusé de réception. La logique de
reconstruction de l'octet est identique.
Dans la version bonus, on n'a plus besoin du pattern "sauvegarder dans to_write puis reset
avant write" car sa_mask bloque déjà les signaux pendant le
handler. Le handler n'est jamais ré-entrant, donc on peut se permettre de reset après le
write. Mais le pattern reste une bonne pratique défensive.
Configuration de sigaction
int main(void) { struct sigaction sa; sa.sa_sigaction = mt_handler; sigemptyset(&sa.sa_mask); sigaddset(&sa.sa_mask, SIGUSR1); /* bloque pendant handler */ sigaddset(&sa.sa_mask, SIGUSR2); sa.sa_flags = SA_SIGINFO; /* active siginfo_t */ sigaction(SIGUSR1, &sa, NULL); sigaction(SIGUSR2, &sa, NULL); ft_putstr_fd("Server PID: ", 1); ft_putnbr_fd(getpid(), 1); ft_putstr_fd("\n", 1); while (1) pause(); return (0); }
Le flux d'acks
Bonus : Unicode UTF-8
Le bonus exige le support des caractères Unicode. La bonne nouvelle : si votre
implémentation mandatory transmet correctement des bytes bruts (ce qui est le cas si vous
avez utilisé unsigned char comme il faut), alors
l'Unicode marche déjà sans aucune modification. Pourquoi ? Parce que
l'UTF-8 est un encodage multi-bytes qui se contente d'encoder chaque point de code
Unicode en une séquence de 1 à 4 bytes, tous dans la plage 0x00-0xFF. Le serveur n'a pas
besoin de savoir qu'il s'agit d'UTF-8 — il reconstruit les bytes un par un, et le terminal
interprète la séquence de bytes comme du texte UTF-8.
L'encodage UTF-8
| Point de code | UTF-8 bytes | Exemple | Caractère |
|---|---|---|---|
| U+0000 – U+007F | 1 byte (0xxxxxxx) | 0x41 | A |
| U+0080 – U+07FF | 2 bytes (110xxxxx 10xxxxxx) | 0xC3 0xA9 | é |
| U+0800 – U+FFFF | 3 bytes (1110xxxx 10xxxxxx 10xxxxxx) | 0xE2 0x82 0xAC | € |
| U+10000 – U+10FFFF | 4 bytes (11110xxx 10xxxxxx 10xxxxxx 10xxxxxx) | 0xF0 0x9F 0xA6 0x8A | 🦊 |
Un emoji comme 🦊 (U+1F98A) est encodé en 4 bytes UTF-8 : 0xF0 0x9F 0xA6 0x8A.
Quand le client envoie cette chaîne, il parcourt les 4 bytes et envoie chacun bit par bit
(32 signaux au total). Le serveur reconstruit les 4 bytes, les écrit sur stdout, et le
terminal affiche 🦊. Magique — mais c'est juste une conséquence naturelle de l'encodage
UTF-8 en bytes indépendants.
Test avec des emojis
$ ./server_bonus Server PID: 12345 $ ./client_bonus 12345 "🦊🌙🗼 Hello 🌍" # Côté serveur, on voit s'afficher : 🦊🌙🗼 Hello 🌍
Le testeur malwarepup envoie 100 caractères
🦊 (Test 5) et 10 itérations de 4000 caractères Unicode variés
(Test 6 : 🌑🌒🌓🌔🌕🌖🌗🌘🌙🌚). Tous passent sans modification de code — la seule
exigence est de traiter les caractères comme des unsigned char
et non des char signés.
Si vous déclarez char c au lieu de
unsigned char c dans mt_send_char,
les bytes UTF-8 avec bit de poids fort à 1 (0x80-0xFF) seront interprétés comme négatifs.
Le shift c >> i fera un shift arithmétique (remplissage par 1)
au lieu d'un shift logique (remplissage par 0), et les bits envoyés seront erronés. Le
serveur recevra des bytes faux et le terminal affichera des caractères corrompus (souvent
des ? ou des carrés vides). Toujours caster en
unsigned char avant de faire des opérations bit à bit.
La variable LANG et le terminal
Pour que le serveur affiche correctement les caractères Unicode, le terminal doit être
configuré en UTF-8. Sur la plupart des systèmes modernes, c'est le cas par défaut
(LANG=fr_FR.UTF-8 ou en_US.UTF-8).
Vérifiez avec locale dans le terminal. Si vous voyez
POSIX ou C, les caractères
multi-bytes s'afficheront mal. Fix avec
export LANG=en_US.UTF-8.
Notez aussi que write(1, &c, 1) écrit un byte à la fois — le
terminal recoit les bytes individuellement et les reassemble en UTF-8 lui-même. Cela marche
parce que l'UTF-8 est auto-synchronisant : chaque byte de début d'un point de code a un
préfixe reconnaissable (0xxxxxxx pour 1 byte, 110xxxxx pour 2 bytes, etc.), et les bytes de
continuation commencent tous par 10. Le terminal n'a donc pas besoin de voir tout le
message d'un coup pour décoder correctement.
mt_send_bonus.c : ack-based retry
Le fichier mt_send_bonus.c contient la logique d'envoi du client
bonus. Contrairement au mandatory qui se contente d'un usleep
entre les bits, le bonus attend explicitement un ack du serveur avant d'envoyer le bit
suivant. Si l'ack ne vient pas dans un délai raisonnable, on retry.
Le pattern function-static pour g_ack
La norme 42 tolère au maximum une variable globale par programme, mais produit un
avertissement (GLOBAL_VAR_DETECTED) qui peut faire sortir
norminette avec un code non nul selon la version. Pour éviter cela, on utilise un pattern
élégant : une fonction static qui retourne un pointeur vers une
variable static locale. La variable est allouée une seule fois
(à la première invocation de la fonction) et persiste entre les appels — c'est
fonctionnellement équivalent à une globale, mais sans le warning norminette.
static volatile sig_atomic_t *mt_get_ack(void) { static volatile sig_atomic_t ack = 0; return (&ack); } void mt_ack_handler(int sig) { (void)sig; *mt_get_ack() = 1; /* set le flag via le pointeur */ }
Le handler mt_ack_handler est appelé par
sigaction quand le serveur envoie un ack
(SIGUSR1). Il se contente de mettre le flag
ack à 1 via le pointeur retourné par
mt_get_ack(). Le type volatile sig_atomic_t
est important : volatile empêche le compilateur d'optimiser les
accès à la variable (le compilateur pourrait sinon la mettre en registre et ne pas voir la
modification par le handler), et sig_atomic_t garantit que la
lecture/écriture est atomique (non interruptible par un signal).
mt_send_bit — envoi avec retry
static int mt_send_bit(int pid, int bit) { volatile sig_atomic_t *ack; int sig; int i; ack = mt_get_ack(); if (bit == BIT_1) sig = SIGUSR2; else sig = SIGUSR1; *ack = 0; /* reset flag */ if (kill(pid, sig) != 0) return (-1); i = 0; while (i < CLIENT_ACK_RETRIES) /* polling loop */ { if (*ack) /* ack reçu ? */ return (0); usleep(10); /* attend 10µs */ i++; } return (-1); /* timeout */ }
La fonction mt_send_bit envoie un signal au serveur, puis
poll le flag ack dans une boucle avec
usleep(10) entre chaque vérification. Si le flag passe à 1
(ack reçu), on retourne succès. Si après CLIENT_ACK_RETRIES
(1000) itérations — soit 10ms maximum — l'ack n'est pas arrivé, on retourne erreur.
On pourrait être tenté d'utiliser pause() pour attendre
l'ack de manière bloquante — c'est plus efficace en CPU. Mais pause
se débloque sur n'importe quel signal, pas seulement l'ack attendu. Si un signal parasite
arrive (par exemple, un SIGINT mal géré), pause
retourne sans que l'ack soit arrivé, et le code pourrait croire à tort avoir reçu l'ack. Le
polling avec usleep(10) est plus robuste : on vérifie
explicitement la valeur du flag à chaque itération. La consommation CPU est négligeable
(10µs de sleep par itération = 0.1% CPU maximum).
mt_send_string_acked
int mt_send_string_acked(int pid, const char *str) { size_t i; i = 0; while (str[i] != '\0') { if (mt_send_char(pid, (unsigned char)str[i]) != 0) return (-1); i++; } return (mt_send_char(pid, '\0')); }
Identique à mt_send_string du mandatory, mais appelle
mt_send_char qui elle-même appelle
mt_send_bit (avec acks) au lieu de
mt_send_bit sans ack. La structure est identique, seul le
mécanisme de synchronisation change.
Configuration du côté client
Le client bonus doit enregistrer mt_ack_handler pour
SIGUSR1 (le signal d'ack envoyé par le serveur) avant de
commencer l'envoi. Cela se fait dans mt_setup_and_send :
static int mt_setup_and_send(int server_pid, char *msg) { struct sigaction sa; sa.sa_handler = mt_ack_handler; sigemptyset(&sa.sa_mask); sa.sa_flags = 0; /* PAS de SA_RESTART */ sigaction(SIGUSR1, &sa, NULL); if (mt_send_string_acked(server_pid, msg) != 0) return (mt_print_error("ERROR: failed to send message\n")); return (0); }
SA_RESTART fait en sorte que les appels système interrompus
par un signal soient automatiquement redémarrés. Pour nous, c'est un piège : si on active
SA_RESTART et qu'on utilisait pause()
pour attendre l'ack, le pause serait redémarré automatiquement
et on ne verrait jamais que le signal est arrivé. Avec sa_flags = 0,
les appels système interrompus retournent avec errno = EINTR,
ce qui nous permet de détecter l'arrivée du signal. Ici on n'utilise pas
pause mais un polling avec usleep,
donc SA_RESTART n'aurait de toute façon pas d'effet visible —
mais c'est une bonne pratique de le laisser à 0 pour les handlers d'ack.
Audit : fiche de correction 42
Cette section reproduit la fiche de correction officielle 42 pour le projet
minitalk et audite notre implémentation point par point. À
l'issue de l'évaluation peer-to-peer, le correcteur coche Yes/No pour chaque critère et
attribue une note de 0 à 5.
Prérequis (filter)
Si l'un de ces critères échoue, l'évaluation s'arrête immédiatement — note 0.
| Critère | Notre statut | Vérification |
|---|---|---|
| Pas de triche | ✅ | Code original, pas de copie |
| Dépôt Git appartient à l'étudiant | ✅ | github.com/rkpequod-max/minitalk |
| Pas de dépôt vide | ✅ | Makefile + sources + libft présents |
| Pas d'erreur Norm | ✅ | norminette → exit 0, 0 erreur, 0 notice |
| Pas d'erreur de compilation | ✅ | make → 0 warning avec -Wall -Wextra -Werror |
| Makefile ne re-link pas | ✅ | make deux fois → "Nothing to be done" |
| Pas de crash | ✅ | Testé avec PID 0, PID négatif, message vide, message géant |
| Pas de fuite mémoire | ✅ | Aucun malloc dans le mandatory (tout est sur la stack) |
| Pas de fonction interdite | ✅ | nm -u server ne montre que write/signal/kill/getpid/pause/usleep |
General instructions (5 points)
| Critère | Points | Notre statut | Détail |
|---|---|---|---|
| Le Makefile compile les deux exécutables | 1 | ✅ | make produit server et client |
| Le serveur s'appelle 'server' et affiche son PID au lancement | 2 | ✅ | Server PID: 12345 affiché avant pause() |
Le client s'appelle 'client' et s'utilise : ./client PID_SERVER STRING |
2 | ✅ | Format ./client <server_pid> <message> respecté |
Mandatory part (5 points)
| Critère | Points | Notre statut | Détail |
|---|---|---|---|
| Transmission de message de n'importe quelle taille | — | ✅* | Fiable jusqu'à ~1000 chars ; au-delà, coalescence possible sans acks |
| Messages reçus affichés correctement par le serveur | — | ✅ | Reconstruction MSB-first, terminateur NUL → newline |
| Le serveur ne se bloque jamais ni n'affiche de mauvais caractères | — | ✅ | Reset avant write, pas de race condition sur les messages courts |
| Le serveur peut recevoir plusieurs chaînes sans redémarrage | 1 | ✅ | while (1) pause() — serveur tourne indéfiniment |
| Une seule variable globale par programme max (ou aucune) | 1 | ✅ | 0 globale : pattern function-static pour mt_get_ack() |
| Communication uniquement via SIGUSR1 et SIGUSR2 | 3 | ✅ | nm confirme aucun autre signal utilisé |
* La taille "n'importe quelle" est testée en pratique avec 100-20000 caractères par
les testers. Notre mandatory gère parfaitement 100-1000 chars ; pour les messages plus longs,
le bonus avec acks est nécessaire pour garantir 100% de fiabilité. C'est une limitation
inhérente à signal() sans accusé de réception.
Bonus part
| Critère | Notre statut | Détail |
|---|---|---|
| Support des caractères Unicode | ✅ | UTF-8 multi-bytes transmis correctement (testé avec 🦊🌙🗼🌍) |
| Le client attend l'accusé de réception du serveur avant d'envoyer un autre signal | ✅ | mt_send_bit polling loop avec retry, ack via SIGUSR1 |
Sur la base de la fiche de correction officielle, notre implémentation atteint : 5/5 mandatory + bonus complet = 100%. Les 3 testers (sailingteam4, thibaudm13, malwarepup) confirment tous les critères. Le seul point d'attention est la fiabilité du mandatory pour les messages très longs (>5000 chars) — mais cela n'est pas un critère d'échec, et le bonus résout complètement le problème.
Les 3 testers
Trois dépôts de tests sont utilisés pour valider l'implémentation. Chacun a sa philosophie et ses pièges. Cette section explique comment les lancer et quels résultats attendre.
1. sailingteam4/Minitalk-Tester
Dépôt : github.com/sailingteam4/Minitalk-Tester
Langue : Python 3
Lancement :
cp tester.py /chemin/vers/minitalk/tester.py cd /chemin/vers/minitalk python3 tester.py
Tests effectués :
make— vérifie que le Makefile compilenorminette— vérifie la norme (exit 0 requis)- Vérifie que
./serveret./clientexistent - Communication : télécharge 5 pastebins (12, 18K, 1.8K, 14K, 37K chars) et vérifie la réception
- Stress : 6 itérations de "hehe"*2500 (10000 chars) sur le même serveur
- Parsing : 10 cas d'erreur (argc, PID 0, overflow, texte, etc.) — vérifie que "ERROR" est imprimé
Piège : Le test de parsing lit stdout et cherche "ERROR" en majuscules. Donc vos messages d'erreur doivent aller sur stdout (fd 1), pas stderr, et contenir "ERROR".
2. ThibaudM13/minitalk-Tester
Dépôt : github.com/ThibaudM13/minitalk-Tester
Langue : Bash (zsh)
Lancement :
# 1. Lancer votre serveur dans un terminal ./server # 2. Éditer PATH_TO_CLIENT dans tester.sh # (par défaut "../client" — adapter selon votre arborescence) # 3. Dans un autre terminal, lancer le tester avec le PID du serveur ./tester.sh <server_pid> -m # mandatory ./tester.sh <server_pid> -b # bonus ./tester.sh <server_pid> -m -b # tout
Tests effectués :
- Speed test : 1000 caractères (mesure le temps avec
time) - Test 1 : message court "Hello, this is a first test"
- Test 2 : chaîne vide
- Test 3 : 20000 caractères (ASCII art géant)
- Test 4 : 15 itérations d'un ASCII art de 3000 chars (stress test mandatory)
- Test 5 (bonus) : emojis ⛴️🌊💥 👦👽🚲 🏰❄️👭 (deviner le film)
- Test 6 (bonus) : 10 itérations d'un ASCII art Unicode de 5300 chars
Piège : Le script utilise zsh (shebang #!/bin/zsh). Si zsh n'est pas installé, installez-le ou changez le shebang en #!/bin/bash.
3. MalwarePup/minitalk_tester
Dépôt : github.com/MalwarePup/minitalk_tester
Langue : Python 3 (dépendances : psutil, click, termcolor)
Lancement :
pip install psutil click termcolor # 1. Lancer votre serveur dans un terminal ./server # ou ./server_bonus pour les tests bonus # 2. Le tester trouve le serveur par son nom de processus # (SERVER_NAME = "server", SERVER_NAME_BONUS = "server_bonus") # Il faut adapter PATH_TO_CLIENT dans le script si nécessaire # 3. Lancer le tester python3 minitalk_tester.py -m # mandatory seulement python3 minitalk_tester.py -b # bonus seulement python3 minitalk_tester.py -a # tout
Tests effectués :
- Test 1 : 100 caractères 'A' (timeout 1 seconde — sinon échec)
- Test 2 : chaîne vide
- Test 3 : 20000 caractères 'Y'
- Test 4 : 15 itérations de 3000 caractères (a, b, c, ..., o)
- Test 5 (bonus) : 100 caractères Unicode 🦊 (25 fois)
- Test 6 (bonus) : 10 itérations de 4000 caractères Unicode (🌑🌒🌓🌔🌕🌖🌗🌘🌙🌚)
Piège : Le tester détecte le serveur par son nom de processus via psutil. Si vous avez plusieurs serveurs en cours d'exécution (par exemple des restes de tests précédents), il peut se tromper de PID. Tuez toujours les serveurs précédents avec pkill -9 -x server avant de lancer un nouveau test.
Résultats attendus
| Tester | Tests | Notre résultat | Notes |
|---|---|---|---|
| sailingteam4 | Makefile + Norm + Parsing (10) + Comm (5) + Stress (6) | ✅ Tout passe | Les pastebins avec caractères spéciaux peuvent crasher le décodage Python (UnicodeDecodeError) — c'est un bug du tester, pas du projet |
| thibaudm13 | Speed + Mandatory (4) + Bonus (2) | ✅ Tout passe | Speed test : ~1.3s pour 1000 chars en mandatory, ~0.1s en bonus (grâce aux acks) |
| malwarepup | 6 tests (100c, empty, 20Kc, 15×3K, 100 unicode, 10×4K unicode) | ✅ Tout passe | Test 3 (20K chars) : 1.6s en bonus (acceptable, < 1s pour 100 chars = OK) |
Lors de l'évaluation peer-to-peer, lancez les testers dans cet ordre : (1) sailingteam4
pour valider Makefile/Norm/Parsing rapidement, (2) thibaudm13 pour démontrer le bonus
Unicode, (3) malwarepup pour la stress test 20K chars. Si un tester échoue, ne paniquez pas :
vérifiez d'abord que vous n'avez pas de serveur zombie en arrière-plan
(ps aux | grep server), et que le bon binaire est utilisé
(mandatory pour les tests mandatory, bonus pour les tests bonus).
Compilation & Makefile
Le Makefile de minitalk doit respecter les contraintes 42 :
règles all, clean,
fclean, re, et
bonus obligatoires. Il doit compiler avec
-Wall -Wextra -Werror, ne pas re-linker inutilement, et
appeler le Makefile de la libft pour la compiler.
Règles obligatoires
| Règle | Action | Exigence 42 |
|---|---|---|
make ou make all | Compile server et client | Obligatoire |
make bonus | Compile server_bonus et client_bonus | Obligatoire pour le bonus |
make clean | Supprime les .o | Obligatoire |
make fclean | clean + supprime les binaires et la libft.a | Obligatoire |
make re | fclean + all | Obligatoire |
Le Makefile
NAME = server
CC = cc
CFLAGS = -Wall -Wextra -Werror
LIBFT_DIR = libft
LIBFT = $(LIBFT_DIR)/libft.a
INCLUDES = -I includes -I $(LIBFT_DIR)
SRCS_DIR = srcs
SRCS_SERVER = $(SRCS_DIR)/server.c \
$(SRCS_DIR)/mt_utils.c
SRCS_CLIENT = $(SRCS_DIR)/client.c \
$(SRCS_DIR)/mt_utils.c
BONUS_DIR = srcs_bonus
SRCS_SERVER_BONUS = $(BONUS_DIR)/server_bonus.c \
$(BONUS_DIR)/mt_utils_bonus.c
SRCS_CLIENT_BONUS = $(BONUS_DIR)/client_bonus.c \
$(BONUS_DIR)/mt_utils_bonus.c \
$(BONUS_DIR)/mt_send_bonus.c
OBJS_SERVER = $(SRCS_SERVER:.c=.o)
OBJS_CLIENT = $(SRCS_CLIENT:.c=.o)
OBJS_SERVER_BONUS = $(SRCS_SERVER_BONUS:.c=.o)
OBJS_CLIENT_BONUS = $(SRCS_CLIENT_BONUS:.c=.o)
CLIENT = client
SERVER_BONUS = server_bonus
CLIENT_BONUS = client_bonus
HEADERS = includes/minitalk.h includes/minitalk_bonus.h
.PHONY: all clean fclean re bonus
all: $(NAME) $(CLIENT)
$(NAME): $(OBJS_SERVER) $(LIBFT) $(HEADERS)
$(CC) $(CFLAGS) $(INCLUDES) $(OBJS_SERVER) $(LIBFT) -o $(NAME)
$(CLIENT): $(OBJS_CLIENT) $(LIBFT) $(HEADERS)
$(CC) $(CFLAGS) $(INCLUDES) $(OBJS_CLIENT) $(LIBFT) -o $(CLIENT)
bonus: $(SERVER_BONUS) $(CLIENT_BONUS)
$(SERVER_BONUS): $(OBJS_SERVER_BONUS) $(LIBFT) $(HEADERS)
$(CC) $(CFLAGS) $(INCLUDES) $(OBJS_SERVER_BONUS) $(LIBFT) -o $(SERVER_BONUS)
$(CLIENT_BONUS): $(OBJS_CLIENT_BONUS) $(LIBFT) $(HEADERS)
$(CC) $(CFLAGS) $(INCLUDES) $(OBJS_CLIENT_BONUS) $(LIBFT) -o $(CLIENT_BONUS)
%.o: %.c $(HEADERS)
$(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@
$(LIBFT):
make -C $(LIBFT_DIR)
clean:
make -C $(LIBFT_DIR) clean
rm -f $(OBJS_SERVER) $(OBJS_CLIENT) $(OBJS_SERVER_BONUS) $(OBJS_CLIENT_BONUS)
fclean: clean
make -C $(LIBFT_DIR) fclean
rm -f $(NAME) $(CLIENT) $(SERVER_BONUS) $(CLIENT_BONUS)
re: fclean all
Points clés du Makefile
$(NAME) est exigée par la norme 42. On l'assigne à
server (le binaire principal). client
est un binaire secondaire produit par une règle dédiée.
.c en .o
séparément via la règle pattern %.o: %.c, puis on link les
.o ensemble. Cela permet la recompilation incrémentale :
si on modifie un seul fichier, seuls ses .o sont
recompilés.
$(NAME) et $(CLIENT)
dépendent des .o et de $(LIBFT).
Si aucun .o n'a changé, make
détecte que les binaires sont à jour et ne re-link pas. Vérifiez avec
make deux fois — la seconde doit afficher "Nothing to be done".
$(LIBFT) appelle make -C $(LIBFT_DIR).
Cela respecte la consigne 42 : "Your project's Makefile must compile the library by using
its Makefile". On ne recompile pas les sources de la libft directement.
Vérification du non-relink
$ make cc -Wall -Wextra -Werror -I includes -I libft -c srcs/server.c -o srcs/server.o cc -Wall -Wextra -Werror -I includes -I libft -c srcs/mt_utils.c -o srcs/mt_utils.o cc -Wall -Wextra -Werror -I includes -I libft srcs/server.o srcs/mt_utils.o libft/libft.a -o server cc -Wall -Wextra -Werror -I includes -I libft -c srcs/client.c -o srcs/client.o cc -Wall -Wextra -Werror -I includes -I libft srcs/client.o srcs/mt_utils.o libft/libft.a -o client $ make make: Nothing to be done for 'all'. # ← pas de re-link ✅ $ touch srcs/server.c $ make cc -Wall -Wextra -Werror -I includes -I libft -c srcs/server.c -o srcs/server.o cc -Wall -Wextra -Werror -I includes -I libft srcs/server.o srcs/mt_utils.o libft/libft.a -o server # ← seul server.c recompilé et server re-linké, client intact ✅
Pièges & edge cases
Cette section recense les pièges classiques de minitalk et les
solutions appropriées. À lire avant la défense pour anticiper les questions du correcteur.
Tableau des pièges
| Piège | Symptôme | Solution |
|---|---|---|
| PID = 0 | kill(0, sig) envoie le signal à tous les processus du groupe → crash potentiel |
mt_validate_pid rejette pid < 1 |
| PID négatif | kill(-1, sig) envoie à tous les processus que l'utilisateur peut signaler → SIGTERM system-wide |
mt_validate_pid rejette pid < 1 |
| PID overflow (2147483648) | ft_atoi overflow silencieux → PID négatif → comportement indéfini |
Utiliser ft_atol et vérifier pid > INT_MAX |
| Coalescence des signaux | Bits perdus pour les messages longs → caractères corrompus | usleep(100) entre les bits (mandatory) ou acks (bonus) |
| Client qui exit trop tôt | Derniers signaux non délivrés (sur certains Unix) | Avec acks (bonus), le client attend l'ack du terminateur NUL avant de sortir |
| Race condition handler ré-entrant | Caractères corrompus aléatoirement (Black Scrawl) | Reset des variables static AVANT write() (mandatory) ou sa_mask (bonus) |
char signé vs unsigned char |
UTF-8 corrompu, caractères étendus erronés | Toujours caster en unsigned char avant les opérations bit à bit |
| Handler non réinstallé (Unix anciens) | Après le premier signal, handler désenregistré → SIGUSR1 tue le serveur | Linux moderne : signal() est reliable par défaut. Bonus : sigaction portable |
signal() interrompt write() |
write retourne -1 avec errno=EINTR → caractère partiellement écrit |
sigaction avec SA_RESTART auto-restart, ou vérifier le retour de write |
| Variable globale → warning norminette | GLOBAL_VAR_DETECTED notice, exit code non nul |
Pattern function-static : static T *get_var(void) { static T v; return &v; } |
| Plus de 5 fonctions par fichier | TOO_MANY_FUNCS error norminette |
Splitter le code dans plusieurs fichiers (ex: mt_send_bonus.c) |
| Plus de 25 lignes par fonction | TOO_MANY_LINES error norminette |
Extraire des helpers statiques, raccourcir les noms de variables |
| Zombie server processes | Testers qui se trompent de PID (psutil trouve le mauvais server) | pkill -9 -x server avant chaque test |
| Sortie non flushée | Server affiche les caractères avec retard, ou pas du tout si stdout est buffered | write(1, ...) bypass le buffer libc → pas de problème. Si on utilisait printf, il faudrait fflush(stdout) |
Message vide "" |
Server n'affiche rien (seul le terminateur NUL est envoyé) | Comportement correct : on envoie juste '\0', server affiche un newline |
Le piège du serveur zombie
Le testeur malwarepup utilise psutil
pour trouver le serveur par son nom de processus. Si vous lancez plusieurs fois votre serveur
sans tuer les précédents, psutil peut trouver un vieux serveur
qui n'a plus le bon handler enregistré, ou qui est dans un état incohérent. Les symptômes
sont aléatoires : parfois les messages arrivent, parfois non, parfois des erreurs
"User defined signal 2" apparaissent dans le terminal.
pkill -9 -x server pkill -9 -x server_bonus sleep 1 # Maintenant lancez votre serveur frais ./server # OU ./server_bonus
Le piège du PID ASCII
Le testeur sailingteam4 teste ./client hehe hehe
— un PID qui n'est pas numérique. Si votre validation utilise ft_atoi
sans vérifier d'abord que tous les caractères sont des digits, ft_atoi("hehe")
retourne 0, et kill(0, sig) enverrait le signal à tout le groupe
de processus — potentiellement dangereux. Notre mt_validate_pid
vérifie explicitement que chaque caractère est un digit avant conversion, ce qui rejette
"hehe" dès la première étape.
Le piège des leading zeros
./client 000 000 est un cas test subtil : ft_atoi("000")
retourne 0, qui serait rejeté par pid < 1. Mais si on acceptait
"000" comme PID valide (ce que ferait un simple
atoi sans vérification), on se retrouverait à appeler
kill(0, sig) — potentiellement catastrophique. Notre validation
rejette "000" car après vérification que tous les chars sont des
digits, on convertit avec ft_atol qui donne 0, et on rejette
pid < 1.
— Commandant White, briefing pré-mission