minitalk · 42 // TECHNICAL GUIDE
SYS:ONLINE REC // 03
Projet 42 · Branche Système

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.

Difficulté
★★☆☆☆
Temps estimé
15 – 30 h
Fonctions autorisées
write · signal · kill · getpid · malloc · free · pause · sleep · usleep · exit
Bonus
sigaction uniquement
// Contrainte clé

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.

« Tout ce que vous transmettez est un bit. Tout ce que vous recevez est un bit. Le reste n'est que patience et synchronisation. »
— Manuel de terrain YoRHa, module 042
01

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èreExigenceDétail
CommunicationSignaux uniquementSIGUSR1 et SIGUSR2 — aucun autre
LancementServer d'abordAffiche Server PID: <pid> au démarrage
ClientFormat strict./client <server_pid> <string>
PerformanceRapide1 seconde pour 100 caractères = colossal
Multi-clientSans restartPlusieurs clients à la suite sans relancer le serveur
NormeNorminette0 erreur, 0 warning -Wall -Wextra -Werror
GlobalesMax 1 par programmeOu aucune — préféré
MakefileRules obligatoiresall, clean, fclean, re, bonus
BonusAcks + Unicodesigaction 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é.

// BUNKER (server)
Lance 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.
// POD (client)
Valide ses arguments (PID serveur + message), puis pour chaque caractère de la chaîne envoie 8 signaux successifs (un par bit, MSB d'abord). Un usleep entre chaque bit évite que les signaux ne s'écrasent mutuellement dans la file d'attente du noyau.
// Conseil de terrain

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.

02

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() — mandatory
API simple : 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.).
// sigaction() — bonus
API structurée : on remplit un 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-vs-sigaction.c// comparaison
/* 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.

// Coalescence — pourquoi on perd des bits
Timeline sans usleep : Client : ──SIGUSR1──SIGUSR1──SIGUSR2──SIGUSR1──SIGUSR2──▶ │ │ │ │ │ Noyau : ▼ ▼ ▼ ▼ ▼ (file) (file) (file) (file) (file) │ │ │ │ │ Server : ──────handler()───────────────────handler()──▶ ▲ ▲ │ │ 3 signaux coalescents 2 signaux coalescents (2 bits perdus !) (1 bit perdu !) Timeline avec usleep(100) : Client : ──SIGUSR1──[100µs]──SIGUSR1──[100µs]──SIGUSR2──▶ │ │ Noyau : ▼ ▼ handler() handler() │ │ Server : ──────────────────▶│─────────────────▶│──────────▶ OK OK Le usleep laisse au serveur le temps de traiter chaque signal avant que le suivant n'arrive. Pas de coalescence.

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.

// Black Scrawl

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.

03

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.

encodage.c// client → 1 octet = 8 signaux
/* 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

// Transmission de "Hi" (2 octets + terminateur NUL)
CLIENT (Pod) SERVER (Bunker) ───────────────── ───────────────── "H" = 0x48 = 0b01001000 bit 7 = 0 kill(USR1) ─────────▶ handler(USR1) → current_char |= 0 << 7 bit 6 = 1 kill(USR2) ─────────▶ handler(USR2) → current_char |= 1 << 6 bit 5 = 0 kill(USR1) ─────────▶ handler(USR1) → current_char |= 0 << 5 bit 4 = 0 kill(USR1) ─────────▶ handler(USR1) → current_char |= 0 << 4 bit 3 = 1 kill(USR2) ─────────▶ handler(USR2) → current_char |= 1 << 3 bit 2 = 0 kill(USR1) ─────────▶ handler(USR1) → current_char |= 0 << 2 bit 1 = 0 kill(USR1) ─────────▶ handler(USR1) → current_char |= 0 << 1 bit 0 = 0 kill(USR1) ─────────▶ handler(USR1) → current_char |= 0 << 0 bit_index < 0 → write('H'), reset "i" = 0x69 = 0b01101001 bit 7 = 0 kill(USR1) ─────────▶ handler(USR1) → ... ... (7 signaux) ... ... (7 handler calls) ... bit 0 = 1 kill(USR2) ─────────▶ handler(USR2) → write('i'), reset '\0' = 0x00 = 0b00000000 8 × kill(USR1) ──────────────▶ 8 × handler(USR1) bit_index < 0 → write('\n'), reset Total : 24 signaux pour "Hi" + 8 pour le terminateur = 32 signaux Durée : ~3.2ms à 100µs/signal + latence scheduler

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.

// Pourquoi NUL et pas la taille ?

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.

04

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.

// Arborescence du projet
minitalk/ ├── Makefile # all / clean / fclean / re / bonus ├── author # login 42 ├── .gitignore # *.o, libft.a, server, client, etc. │ ├── includes/ │ ├── minitalk.h # prototypes mandatory + defines │ └── minitalk_bonus.h # prototypes bonus + defines │ ├── libft/ # librairie partagée (compilée via son Makefile) │ ├── Makefile │ ├── libft.h # 14 prototypes │ ├── ft_atoi.c # atoi strict (digits + signe) │ ├── ft_atol.c # atol pour validation PID (long) │ ├── ft_bzero.c # memset(s, 0, n) │ ├── ft_calloc.c # malloc + bzero avec overflow check │ ├── ft_isdigit.c # '0'..'9' │ ├── ft_itoa.c # int → string │ ├── ft_memset.c # memset de base │ ├── ft_putchar_fd.c # write(fd, &c, 1) │ ├── ft_putnbr_fd.c # write int recursively │ ├── ft_putstr_fd.c # write(fd, s, strlen(s)) │ ├── ft_strchr.c # strchr │ ├── ft_strdup.c # malloc + strcpy │ ├── ft_strlen.c # strlen │ └── ft_strncmp.c # strncmp │ ├── srcs/ # MANDATORY │ ├── server.c # main() + mt_handler() — signal() │ ├── client.c # main() — validation + send │ └── mt_utils.c # mt_validate_pid, mt_send_char, mt_send_string │ └── srcs_bonus/ # BONUS ├── server_bonus.c # main() + mt_handler() — sigaction + acks ├── client_bonus.c # main() — validation + setup sigaction ├── mt_utils_bonus.c # mt_validate_pid (même logique) └── mt_send_bonus.c # mt_send_bit (ack-based), mt_send_char, mt_send_string_acked

Dépendances entre fichiers

// Graphe de dépendances
MANDATORY : client.c ───────▶ mt_utils.c ────▶ libft.a │ │ │ │ ├─ mt_validate_pid ─▶ ft_atol │ └─ mt_send_string ──▶ ft_strlen (via send_char) │ └─ kill, usleep, ft_putstr_fd ──────▶ libft.a server.c ─────────────────────────▶ libft.a │ │ ├─ signal, pause, getpid └─ ft_putstr_fd, ft_putnbr_fd ├─ mt_handler (signal handler) └─ write (output) BONUS : client_bonus.c ──▶ mt_send_bonus.c ──▶ mt_utils_bonus.c ──▶ libft.a │ │ │ │ ├─ mt_ack_handler └─ mt_validate_pid ─▶ ft_atol │ ├─ mt_send_bit (acks) │ └─ mt_send_string_acked │ └─ sigaction, kill server_bonus.c ───────────────────▶ libft.a │ ├─ sigaction (SA_SIGINFO) ├─ mt_handler (siginfo_t *info → si_pid) ├─ kill(info->si_pid, SIGUSR1) # ack └─ write (output)

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}".

// Bonne pratique

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.

05

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

srcs/server.c// handler mandatory
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.

// Subtilité critique — reset AVANT write

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

srcs/server.c// main mandatory
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.

// Pourquoi pas sleep(1) ?

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.

06

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.

srcs/client.c// main mandatory
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

// 1. argc
On doit avoir exactement 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.
// 2. mt_validate_pid
Vérifie que argv[1] ne contient que des digits, et que la valeur convertie est dans [1, INT_MAX]. Rejette "0", "-1", "hehe", "2147483648" (overflow), "000" (leading zeros).
// 3. kill(pid, 0)
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.
// 4. mt_send_string
Envoie la chaîne bit par bit. Si un 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.
// IMPORTANT — erreur sur stdout

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é.

07

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

srcs/mt_utils.c// validation 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

srcs/mt_utils.c// envoi d'un octet
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

srcs/mt_utils.c// envoi d'une chaîne
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.

// Piège — cast unsigned char

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.

08

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) :

naive_handler.c// BUG : reset après write
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, &current_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

// Race condition — handler ré-entrant pendant write()
Handler principal (octet 'A' complet, bit_index = -1) 1. bit_index-- → -1 2. write(1, ¤t_char, 1) ← write() bloque (pipe plein?) │ │ NOUVEAU SIGUSR2 arrive pendant write ! │ ├─▶ Handler ré-entrant (nouveau bit pour l'octet suivant) : │ 1. current_char |= (1 << 7) ← CORROMPU ! current_char valait encore 'A' │ 2. bit_index-- → 6 ← bit_index valait -1, maintenant 6 │ 3. bit_index < 0 ? NON (6 >= 0) │ 4. return │ 3. (retour du handler ré-entrant, write continue) 4. current_char = 0 ← RESET mais on a déjà corrompu l'octet suivant ! 5. bit_index = 7 ← RESET mais bit_index était à 6, pas -1 Résultat : - Le bit reçu pendant write() a été OR-ed dans current_char (qui valait encore 'A') - Puis current_char a été reset à 0 → bit perdu - bit_index a décrémenté à 6 au lieu de rester à 7 → décalage de tous les bits suivants - Tous les caractères suivants seront corrompus C'est le Black Scrawl : corruption silencieuse, pas de crash, pas d'erreur. Le serveur affiche des caractères erronés de manière aléatoire.

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.

srcs/server.c// CORRIGÉ : reset avant write
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.

// Leçon générale

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.

srcs_bonus/server_bonus.c// sa_mask bloque les signaux
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).

09

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 :

  1. Portabilité : comportement garanti identique sur tous les Unix (POSIX.1-2001). signal a un comportement variable (réinstallation du handler ou non selon l'implémentation).
  2. Masquage : sa_mask permet de bloquer d'autres signaux pendant le handler, éliminant la ré-entrance.
  3. Métadonnées : avec SA_SIGINFO, le handler reçoit un siginfo_t * qui contient si_pid (PID de l'émetteur) — indispensable pour renvoyer un ack au bon client.

Le handler bonus

srcs_bonus/server_bonus.c// handler avec ack
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, &current_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.

// Note sur le reset

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

srcs_bonus/server_bonus.c// main du serveur bonus
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

// Communication ack-based — 1 bit = 1 aller + 1 retour
CLIENT (Pod) SERVER (Bunker) ───────────────── ───────────────── Préparation : ack = 0 kill(srv, SIGUSR2) ──────────▶ handler(SIGUSR2) [attente ack] current_char |= (1 << bit_index) bit_index-- kill(client, SIGUSR1) ──────▶ ack = 1 (handler retourne, pause() reprend) ack == 1 ? OUI → bit suivant Si ack ne vient pas dans CLIENT_ACK_RETRIES * 10µs : retry kill(srv, SIGUSR2) (max 3 retries, puis erreur) Avantages : - Pas de coalescence (chaque bit est explicitement synchronisé) - Détection d'erreur (si serveur meurt, retry puis échec) - Permet des messages de n'importe quelle taille Inconvénient : - 2× plus de signaux (aller + retour pour chaque bit) - Plus lent en pratique (latence aller-retour)
10

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 codeUTF-8 bytesExempleCaractère
U+0000 – U+007F1 byte (0xxxxxxx)0x41A
U+0080 – U+07FF2 bytes (110xxxxx 10xxxxxx)0xC3 0xA9é
U+0800 – U+FFFF3 bytes (1110xxxx 10xxxxxx 10xxxxxx)0xE2 0x82 0xAC
U+10000 – U+10FFFF4 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

terminal// test Unicode
$ ./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.

// Piège — char signé

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.

11

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.

srcs_bonus/mt_send_bonus.c// pattern function-static
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

srcs_bonus/mt_send_bonus.c// envoi d'un bit avec ack
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.

// Pourquoi usleep(10) et pas pause() ?

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

srcs_bonus/mt_send_bonus.c// envoi de la chaîne
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 :

srcs_bonus/client_bonus.c// setup sigaction
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);
}
// Pourquoi PAS de SA_RESTART ?

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.

12

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èreNotre statutVérification
Pas de tricheCode original, pas de copie
Dépôt Git appartient à l'étudiantgithub.com/rkpequod-max/minitalk
Pas de dépôt videMakefile + sources + libft présents
Pas d'erreur Normnorminette → exit 0, 0 erreur, 0 notice
Pas d'erreur de compilationmake → 0 warning avec -Wall -Wextra -Werror
Makefile ne re-link pasmake deux fois → "Nothing to be done"
Pas de crashTesté avec PID 0, PID négatif, message vide, message géant
Pas de fuite mémoireAucun malloc dans le mandatory (tout est sur la stack)
Pas de fonction interditenm -u server ne montre que write/signal/kill/getpid/pause/usleep

General instructions (5 points)

CritèrePointsNotre statutDé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èrePointsNotre statutDé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èreNotre statutDé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
// Auto-évaluation

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.

13

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

// Tester Python — Makefile + Norminette + Parsing + Communication

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 compile
  • norminette — vérifie la norme (exit 0 requis)
  • Vérifie que ./server et ./client existent
  • 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

// Tester Bash — Speed test + Mandatory + Bonus

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

// Tester Python — 6 tests structurés avec timing

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

TesterTestsNotre résultatNotes
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)
// Conseil de défense

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).

14

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ègleActionExigence 42
make ou make allCompile server et clientObligatoire
make bonusCompile server_bonus et client_bonusObligatoire pour le bonus
make cleanSupprime les .oObligatoire
make fcleanclean + supprime les binaires et la libft.aObligatoire
make refclean + allObligatoire

Le Makefile

Makefile// règles 42
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) = server
La règle $(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.
// Compilation par .o
On compile chaque .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.
// Pas de re-link
Les règles $(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 compilée via son Makefile
La règle $(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

terminal// test 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 ✅
15

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ègeSymptômeSolution
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.

// À faire avant chaque test
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.

« Les androïdes ne meurent pas d'un bug — ils meurent d'une omission. Vérifiez chaque entrée, chaque retour, chaque hypothèse. »
— Commandant White, briefing pré-mission