pipex
// Une réécriture pédagogique du comportement < file1 cmd1 | cmd2 > file2 — la mécanique des pipes shell.
Recoder pipex, c'est plonger dans le triptyque fondateur d'UNIX :
fork, pipe, execve. Trois appels système qui,
combinés avec dup2 et waitpid, permettent de
chaîner N commandes comme le fait le shell chaque jour. Ce guide dissèque
l'implémentation fichier par fichier, du fork() initial jusqu'à la
libération des pipes, sans oublier le bonus here_doc.
Le sujet exige une équivalence stricte avec file1 cmd1 | cmd2 file2 pour la
partie mandatory. Le bonus ajoute le here_doc (limiter <<) et le
chaînage de N commandes avec N-1 pipes. Aucune fonction de la libc en dehors de
open, read, write,
close, malloc, free,
perror, strerror, access,
dup, dup2, execve,
exit, fork, pipe,
unlink, wait, waitpid,
wait3, wait4, signal,
kill et les fonctions de libft ne peut être appelée.
Le projet
pipex reproduit le comportement d'une ligne shell qui enchaîne deux
commandes en les connectant par un pipe, avec une redirection d'entrée depuis un
fichier et une redirection de sortie vers un autre fichier. Derrière cette apparente
simplicité se cache toute la machinerie UNIX de gestion des processus.
Contraintes du sujet PDF
Le PDF du sujet impose un cadre strict :
- Le programme s'appelle
pipexet prend exactement 4 arguments pour la partie mandatory :./pipex file1 cmd1 cmd2 file2 - Le comportement doit être strictement équivalent à :
< file1 cmd1 | cmd2 > file2 - Le bonus gère :
./pipex here_doc LIMITER cmd1 cmd2 fileéquivalent àcmd1 << LIMITER | cmd2 >> file - Le bonus gère aussi plusieurs commandes :
./pipex file1 cmd1 cmd2 cmd3 ... cmdn file2 - Gestion d'erreurs minimale : ne pas crasher sur un fichier inexistant, afficher un message
- La
libftest autorisée - Pas de fuite de mémoire, pas de fd non fermé, pas de zombie
Beaucoup d'étudiants pensent que cmd1 doit terminer avant que
cmd2 commence. C'est faux : les deux commandes s'exécutent
concurrently, exactement comme le shell. Le pipe fait office de tampon
entre les deux. C'est ce qu'on doit reproduire avec fork().
Fonctions autorisées
La liste blanche du sujet est volontairement restreinte aux primitives UNIX de manipulation de fichiers, de processus et de descripteurs :
| Famille | Fonctions | Usage dans pipex |
|---|---|---|
| Fichiers / I/O | open read write close unlink access | Ouvrir infile/outfile, lire here_doc, vérifier le PATH |
| Mémoire | malloc free perror strerror | Allouer args/paths, messages d'erreur |
| Descripteurs | dup dup2 | Rediriger STDIN/STDOUT vers pipes/fichiers |
| Processus | fork exit wait waitpid wait3 wait4 kill | Créer les children, attendre leur fin |
| Exécution | execve | Remplacer un child par la commande |
| Communication | pipe | Créer le canal entre cmd1 et cmd2 |
| Signaux | signal | Bonus : gérer Ctrl-C pendant here_doc |
| Perso | toutes les fonctions de libft | ft_strsplit, ft_strjoin, ft_strcmp... |
Équivalence avec le shell
La spécification se résume en deux lignes shell :
$ ./pipex infile "ls -l" "wc -l" outfile # strictement équivalent à : $ < infile ls -l | wc -l > outfile $ ./pipex here_doc EOF "cat" "wc -c" outfile # strictement équivalent à : $ cat << EOF | wc -c >> outfile
Notez la différence critique entre les deux : le mode normal utilise
O_TRUNC (le outfile est écrasé), tandis que le mode here_doc
utilise O_APPEND (on ajoute à la fin). C'est géré dans
init_pipex via un test sur argv[1].
Lancez les deux versions et comparez avec diff. Si votre pipex et le
shell produisent le même outfile byte-à-byte, vous êtes bon : diff <(./pipex infile "ls" "wc" out1) <(< infile ls | wc > out2).
Les processus UNIX
Tout le projet repose sur une idée simple : chaque commande doit s'exécuter dans
son propre processus. Le processus appelant (le parent, ici pipex)
ne peut pas se permettre d'appeler execve directement, car cet appel
remplace le processus courant — il n'y aurait alors plus personne pour lancer
la commande suivante. D'où l'usage systématique de fork().
fork() — cloner le processus
fork() crée une copie exacte du processus appelant. Le child
hérite d'une copie de la mémoire, du même ensemble de descripteurs de fichiers
(partagés au niveau kernel, mais référencés des deux côtés), du même environnement.
La seule différence : la valeur de retour de fork().
| Côté | Retour de fork() | Action typique |
|---|---|---|
| Parent | pid > 0 (PID du child) | Continuer la boucle, fermer les fds, waitpid |
| Child | pid == 0 | Rediriger I/O, fermer les fds inutiles, execve |
| Erreur | pid < 0 | error_exit("fork") |
Le pattern dans main applique exactement ce schéma pour chaque commande :
i = 0; while (i < px->cmd_count) { pid = fork(); if (pid < 0) error_exit("fork"); if (pid == 0) /* child */ run_child(&px, argv[cmd_offset + i], envp, i); i++; }
Parce qu'execve ne retourne pas en cas de succès. Si pipex appelait
execve pour cmd1, le processus pipex serait remplacé
par cmd1 — impossible alors de lancer cmd2. Le fork crée un
child sacrifiable : c'est lui qui devient cmd1 via execve, et le
parent survit pour lancer cmd2.
execve() — remplacer le processus
execve(path, argv, envp) remplace complètement l'image mémoire du
processus appelant par le binaire pointé par path. Le PID ne change
pas, les descripteurs ouverts sont conservés (sauf O_CLOEXEC), mais le
code, le data, le heap, la stack sont écrasés. execve ne retourne qu'en cas
d'échec.
void execute_cmd(char *cmd, char **envp) { char **args; char *path; args = ft_strsplit(cmd, ' '); if (!args || !args[0]) error_exit("command not found"); path = find_path(args[0], envp); execve(path, args, envp); /* ne retourne JAMAIS si succès */ perror(args[0]); /* ici = échec, on explique */ free(path); free_split(args); exit(127); /* code conventionnel : cmd introuvable */ }
Tout ce qui se trouve après execve dans le code n'est exécuté que si
execve a échoué. C'est pourquoi on peut y mettre perror et
exit(127) sans test : si on arrive là, c'est forcément une erreur.
waitpid() — attendre la fin des enfants
Une fois que le parent a lancé tous ses children par fork, il doit les
attendre. Sans cela, les children deviendraient des zombies (processus
terminés mais toujours présents dans la table du kernel). waitpid(-1, NULL, 0)
bloque jusqu'à ce qu'un child se termine — on l'appelle dans une boucle.
if (px.infile != -1) close(px.infile); /* le parent ferme TOUT */ close(px.outfile); close_all_pipes(&px); i = 0; while (i < px.cmd_count) { waitpid(-1, NULL, 0); /* blocage jusqu'à la fin d'un child */ i++; } free_pipes(px.pipes, px.cmd_count - 1);
Erreur courante : penser qu'il faut attendre cmd1 avant de lancer cmd2. Non : tous les forks sont lancés d'abord, puis on attend. Sinon le pipe se remplit (buffer kernel de 64 KB) et cmd1 bloque. L'ordre est : (1) fork tous les children, (2) fermer tout dans le parent, (3) waitpid en boucle.
Arbre de processus pour 2 commandes
Les pipes
Un pipe est un canal de communication unidirectionnel entre deux
processus. Il est créé par l'appel système pipe(int fds[2]) qui
renvoie deux descripteurs : fds[0] est l'extrémité de
lecture (READ_END), fds[1] est l'extrémité d'écriture
(WRITE_END). Tout ce qu'on écrit dans fds[1] est lisible depuis
fds[0] — dans l'ordre, FIFO.
pipe() et create_pipes()
Pour N commandes, il faut N-1 pipes. La fonction create_pipes alloue
un tableau 2D et appelle pipe() pour chacun :
int **create_pipes(int count) { int **pipes; int i; if (count <= 0) /* une seule cmd = aucun pipe */ return (NULL); pipes = malloc(sizeof(int *) * count); if (!pipes) error_exit("malloc"); i = 0; while (i < count) { pipes[i] = malloc(sizeof(int) * 2); if (!pipes[i]) error_exit("malloc"); if (pipe(pipes[i]) < 0) error_exit("pipe"); i++; } return (pipes); }
La structure t_pipex stocke tout l'état partagé :
typedef struct s_pipex { int infile; /* fd du fichier d'entrée (-1 si here_doc) */ int outfile; /* fd du fichier de sortie */ int **pipes; /* tableau [N-1][2] de pipes */ int cmd_count; /* nombre de commandes */ int here_doc; /* 1 si mode here_doc, 0 sinon */ char *limiter; /* délimiteur EOF pour here_doc */ } t_pipex;
Le flux de données entre 2 children
Chaque child ne voit que son stdin et son stdout. Grâce à dup2,
le child ne sait pas qu'il lit depuis un pipe plutôt que depuis le terminal —
l'abstraction est totale. C'est toute la beauté du design UNIX : les programmes sont
agnostiques à la source/destination de leurs données.
Pourquoi fermer les fds inutilisés ?
C'est le piège n°1 de pipex. Quand un child ne ferme pas toutes les extrémités de pipe dont il n'a pas besoin, l'autre child peut bloquer indéfiniment.
Le read() sur l'extrémité de lecture d'un pipe ne renvoie
EOF (retour 0) que lorsque toutes les extrémités d'écriture
sont fermées. Si le child 2 garde ouvert pipe[0][WRITE_END] « au cas
où », le kernel considère qu'un writer est encore potentiellement présent. Donc le
read de cmd2 (via wc) attendra pour toujours la prochaine ligne — même si
cmd1 a terminé. Résultat : pipex se fige, il faut Ctrl-C.
D'où l'importance de close_all_pipes, appelée dans chaque child juste
avant execve :
static void close_all_pipes(t_pipex *px) { int i; i = 0; while (i < px->cmd_count - 1) { close(px->pipes[i][READ_END]); close(px->pipes[i][WRITE_END]); i++; } }
Le child fait ses dup2 d'abord (qui duplique le fd dans
STDIN/STDOUT), puis ferme tout. Comme dup2 crée une nouvelle
référence au même canal, fermer l'original n'affecte pas STDIN/STDOUT. L'ordre est
crucial.
Si vous fermez le fd avant le dup2, vous redirigez STDIN vers un
fd invalide — comportement indéfini. Toujours dup2 avant close.
Redirection I/O
Le shell réalise les redirections <, >,
<<, >> en manipulant les descripteurs
0 (STDIN) et 1 (STDOUT). Pipex fait exactement la même chose avec
dup2(oldfd, newfd), qui fait pointer newfd vers le
même fichier que oldfd.
dup2() — dupliquer un descripteur
dup2(oldfd, newfd) ferme newfd s'il était ouvert, puis le
fait pointer sur la même entrée de la table des fichiers que oldfd. Après
l'appel, écrire dans newfd équivaut à écrire dans oldfd.
| Appel | Effet | Équivalent shell |
|---|---|---|
dup2(infile, STDIN_FILENO) | STDIN ← infile | < infile |
dup2(outfile, STDOUT_FILENO) | STDOUT → outfile | > outfile |
dup2(pipe[0][READ_END], STDIN_FILENO) | STDIN ← sortie du pipe précédent | (connexion entre cmds) |
dup2(pipe[i][WRITE_END], STDOUT_FILENO) | STDOUT → entrée du pipe suivant | (connexion entre cmds) |
run_child() — orchestration des redirections
La fonction run_child décide quelles redirections appliquer selon
l'index de la commande dans la chaîne. Le premier child lit l'infile, le
dernier écrit dans l'outfile, les autres utilisent les pipes :
static void run_child(t_pipex *px, char *cmd, char **envp, int index) { if (index == 0) /* première cmd : infile */ dup2(px->infile, STDIN_FILENO); else /* sinon : pipe précédent */ dup2(px->pipes[index - 1][READ_END], STDIN_FILENO); if (index == px->cmd_count - 1) /* dernière cmd : outfile */ dup2(px->outfile, STDOUT_FILENO); else /* sinon : pipe suivant */ dup2(px->pipes[index][WRITE_END], STDOUT_FILENO); if (px->infile != -1) close(px->infile); close(px->outfile); close_all_pipes(px); /* ferme TOUT ce qui n'est pas STDIN/STDOUT */ execute_cmd(cmd, envp); /* execve — pas de retour */ }
Parce qu'après execve, le code de pipex est remplacé — il est trop tard
pour faire quoi que ce soit. Les redirections doivent être en place avant
que la nouvelle image (ls, wc, cat...) ne démarre. Heureusement, execve
préserve les descripteurs ouverts (sauf O_CLOEXEC), donc ls lit
tranquilement son STDIN qui pointe maintenant vers le pipe.
Le flux complet d'une exécution
Notez que le parent ferme lui aussi toutes les extrémités de pipe après les
forks. C'est essentiel : tant que le parent garde ouvert
pipes[0][WRITE_END], le read de child 2 attendra
indéfiniment, pensant que le parent pourrait encore écrire.
Chaque fd de pipe doit être fermé dans tous les processus qui ne l'utilisent
pas directement : le parent, et chaque child sauf celui qui en a besoin (et encore,
seulement après le dup2). Comptez vos close() : pour N commandes, il y a
2(N-1) extrémités de pipe, chacune devant être fermée N+1 fois (1 parent + N children).
Recherche du PATH
Quand vous tapez ls dans le shell, il ne sait pas immédiatement où
trouver le binaire. Il consulte la variable d'environnement $PATH, qui
contient une liste de répertoires séparés par :, et cherche un fichier
exécutable nommé ls dans chacun. pipex doit faire
exactement la même chose : execve exige un chemin absolu (ou relatif),
il ne fait aucune recherche dans $PATH.
Parce que execve prend un chemin de fichier, pas un nom de
commande. Si vous passez "ls", le kernel cherche un fichier littéralement
nommé ls dans le répertoire courant — et échoue. Il faut donc convertir
"ls" en "/bin/ls" (ou "/usr/bin/ls") avant
l'appel. C'est le boulot de find_path.
find_path() — résoudre une commande en chemin absolu
char *find_path(char *cmd, char **envp) { char *path_env; char **paths; char *tmp; char *full; int i; if (cmd[0] == '/' || cmd[0] == '.') /* chemin absolu/relatif déjà donné */ return (ft_strdup(cmd)); path_env = get_path_env(envp); if (!path_env) return (ft_strdup(cmd)); /* pas de PATH → on tente quand même */ paths = ft_strsplit(path_env, ':'); if (!paths) return (ft_strdup(cmd)); i = 0; while (paths[i]) { tmp = ft_strjoin(paths[i], "/"); /* "/usr/bin" + "/" */ full = ft_strjoin(tmp, cmd); /* + "ls" → "/usr/bin/ls" */ free(tmp); if (access(full, F_OK) == 0) /* le fichier existe ? */ { free_split(paths); return (full); /* GAGNÉ */ } free(full); i++; } free_split(paths); return (ft_strdup(cmd)); /* rien trouvé → execve échouera proprement */ }
Parsing de $PATH
L'environnement envp est un tableau de chaînes "KEY=VALUE".
On cherche celle qui commence par "PATH=", puis on saute les 5 premiers
caractères pour récupérer la valeur :
static char *get_path_env(char **envp) { int i; i = 0; while (envp[i]) { if (ft_strncmp(envp[i], "PATH=", 5) == 0) return (envp[i] + 5); /* pointe juste après "PATH=" */ i++; } return (NULL); /* PATH absent de l'environnement */ }
Exemple concret. Si $PATH vaut :
PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
Alors ft_strsplit(path_env, ':') produit le tableau :
paths[0] = "/usr/local/bin" paths[1] = "/usr/bin" paths[2] = "/bin" paths[3] = "/usr/sbin" paths[4] = "/sbin" paths[5] = NULL
Et pour la commande "ls", on essaie successivement
/usr/local/bin/ls (non), /usr/bin/ls (non),
/bin/ls (oui !) — et on retourne ce dernier chemin.
access() — vérifier l'existence d'un fichier
access(path, mode) vérifie si le fichier est accessible selon le mode
demandé. F_OK teste juste l'existence, R_OK la lecture,
W_OK l'écriture, X_OK l'exécution. Retourne 0 si OK, -1 sinon.
| Mode | Test | Usage typique |
|---|---|---|
F_OK | Le fichier existe | Recherche dans PATH (pipex) |
X_OK | Exécutable par l'utilisateur courant | Vérification supplémentaire (bonus) |
R_OK | Lisible | Vérifier infile avant open |
W_OK | Inscriptible | Vérifier outfile |
L'implémentation utilise seulement F_OK (le fichier existe). Une version
plus robuste utiliserait X_OK pour s'assurer que le fichier est bien
exécutable. Le sujet n'exige pas cette distinction : si execve échoue
ensuite (parce que le fichier n'est pas exécutable), le perror gère
l'erreur.
Dans la boucle de find_path, à chaque itération on malloc
tmp puis full. Si on oublie le free(tmp) ou
le free(full) avant de passer à l'itération suivante, c'est une fuite par
répertoire testé. La moulinette détectera ça avec Valgrind.
Parsing des commandes
Une commande shell comme grep "hello world" file.txt doit donner trois
arguments : grep, hello world (avec l'espace !),
et file.txt. La fonction ft_strsplit de la libft ne
suffit pas : elle découperait en 4 tokens parce qu'elle ne comprend pas les quotes.
C'est pourquoi pipex embarque son propre split_cmd avec gestion des
quotes.
Une commande comme echo 'hello world' doit produire 2 arguments
(echo et hello world), pas 3 (echo,
hello, world). Les guillemets regroupent ce qui est
à l'intérieur en un seul token, même s'il contient des espaces. Sans ça, on perdrait
une fonctionnalité essentielle du shell.
count_tokens() — première passe
La fonction parcourt la chaîne une première fois pour compter le nombre de tokens.
Elle maintient un état quote qui vaut 0 (hors quote), '
ou ". Un token commence quand on est hors quote, sur un non-espace,
après un espace (ou en début de chaîne).
static int count_tokens(char *str) { int count; int i; char quote; count = 0; i = 0; quote = 0; /* 0 = hors quote */ while (str[i]) { if (!quote && (str[i] == '\'' || str[i] == '"')) quote = str[i]; /* on entre dans une quote */ else if (quote && str[i] == quote) quote = 0; /* on sort de la quote */ if (!quote && str[i] != ' ' && (i == 0 || str[i - 1] == ' ')) count++; /* début de token hors quote */ i++; } return (count); }
extract_token() — deuxième passe
Une fois le compte connu, on alloue le tableau et on extrait chaque token. La logique
de quote est identique, mais au lieu de compter, on note le
start et on mesure la len jusqu'au prochain séparateur
(espace hors quote).
static char *extract_token(char *str, int *pos) { char *token; int start; int len; char quote; start = *pos; quote = 0; while (str[*pos] && (!quote && str[*pos] == ' ')) (*pos)++; /* skip espaces de tête */ start = *pos; while (str[*pos] && (quote || str[*pos] != ' ')) { if (!quote && (str[*pos] == '\'' || str[*pos] == '"')) quote = str[*pos]; else if (quote && str[*pos] == quote) quote = 0; (*pos)++; } len = *pos - start; token = malloc(sizeof(char) * (len + 1)); if (!token) return (NULL); ft_strncpy(token, str + start, len); token[len] = '\0'; return (token); }
Différence avec ft_strsplit
Le tableau ci-dessous résume la différence de comportement :
| Entrée | ft_strsplit(cmd, ' ') | split_cmd(cmd) |
|---|---|---|
"ls -l" | [ls, -l] | [ls, -l] |
"echo hello world" | [echo, hello, world] | [echo, hello, world] |
"echo 'hello world'" | [echo, 'hello, world'] | [echo, 'hello world'] |
"grep \"hello world\" file" | [grep, "hello, world", file] | [grep, "hello world", file] |
'cat "a b"' | 4 tokens erronés | 2 tokens corrects |
L'implémentation actuelle conserve les caractères quote dans le token
('hello world' reste 'hello world' avec les apostrophes).
Le shell les enlève. Pour un pipex parfait, il faudrait une étape de plus qui retire
les quotes. Le sujet ne l'exige pas strictement, mais certains correcteurs testent
ce comportement.
Une version plus complète gérerait aussi : les échappements \", les
variables $VAR dans les doubles quotes, l'expansion ~.
Ces features sont hors sujet pour pipex (elles relèvent de Minishell), mais
savoir que split_cmd ne fait que la moitié du travail vous prépare pour la suite.
Bonus · multi-pipes & here_doc
Le sujet de pipex propose deux bonus indissociables : gérer N commandes
chaînées par N-1 pipes, et gérer le mode here_doc qui remplace l'infile
par une saisie interactive terminée par un délimiteur. L'implémentation actuelle
supporte les deux dès la partie mandatory — c'est la structure t_pipex
qui le permet.
Multi-pipes — N commandes
Pour N commandes, on crée N-1 pipes. Le child i lit depuis le pipe
i-1 (sauf le premier, qui lit l'infile) et écrit dans le pipe
i (sauf le dernier, qui écrit dans l'outfile). C'est exactement ce que
run_child implémente.
La boucle de main est volontairement générique :
cmd_offset = px.here_doc ? 3 : 1; px.pipes = create_pipes(px.cmd_count - 1); i = 0; while (i < px.cmd_count) { pid = fork(); if (pid < 0) error_exit("fork"); if (pid == 0) run_child(&px, argv[cmd_offset + i], envp, i); i++; }
Notez que cmd_count est calculé dans init_pipex selon le mode :
- Mode normal :
argc - 2(on retire infile et outfile) - Mode here_doc :
argc - 4(on retirehere_doc, le limiter, et outfile, plus on compte en plus)
here_doc — saisie interactive
Le here_doc du shell (<<) permet d'injecter du texte tapé au
clavier comme entrée d'une commande, jusqu'à un délimiteur. Dans pipex, on ne crée
pas de fichier temporaire — on utilise un pipe pour transporter les données
du parent (qui lit stdin) vers le child (qui consommera via STDIN).
Parce que les fichiers temporaires sont sales : il faut choisir un nom unique, le créer, écrire dedans, le rouvrir en lecture, le supprimer à la fin. Beaucoup de points de défaillance. Un pipe est atomique, sans état sur disque, et se ferme naturellement. Le parent écrit dans le pipe pendant la phase de saisie, ferme l'extrémité d'écriture, et le child lit le contenu comme si c'était un fichier.
L'implémentation :
int handle_here_doc(char *limiter) { int pipe_fd[2]; char *line; if (pipe(pipe_fd) < 0) error_exit("pipe"); while (1) { ft_putstr_fd("heredoc> ", 1); line = read_line(); if (ft_strcmp(line, limiter) == 0) { free(line); break ; } write(pipe_fd[WRITE_END], line, ft_strlen(line)); write(pipe_fd[WRITE_END], "\n", 1); free(line); } close(pipe_fd[WRITE_END]); /* TRÈS IMPORTANT : déclenche EOF */ return (pipe_fd[READ_END]); /* sera le "infile" du child */ }
La lecture se fait caractère par caractère pour ne pas consommer le retour chariot suivant dans stdin :
static char *append_char(char *line, char c) { char *new; int len; len = ft_strlen(line); new = malloc(sizeof(char) * (len + 2)); if (!new) return (NULL); ft_strcpy(new, line); new[len] = c; new[len + 1] = '\0'; free(line); return (new); } static char *read_line(void) { char *line; char buf[2]; int ret; line = ft_strnew(1); buf[1] = '\0'; ret = read(STDIN_FILENO, buf, 1); while (ret > 0 && buf[0] != '\n') { line = append_char(line, buf[0]); ret = read(STDIN_FILENO, buf, 1); } return (line); }
Sans cette fermeture après la boucle de saisie, le read du child ne
recevra jamais EOF — il attendra indéfiniment une nouvelle ligne. C'est exactement
le même piège que pour les pipes entre commandes. Si votre here_doc bloque à la fin,
vérifiez d'abord ce close.
Mode >> append
Le mode here_doc implique aussi l'append sur l'outfile (équivalent
>> du shell). C'est géré dans init_pipex par le choix du
flag passé à open :
static void init_pipex(t_pipex *px, int argc, char **argv) { px->here_doc = 0; px->infile = -1; if (is_here_doc(argv[1])) { px->here_doc = 1; px->limiter = argv[2]; px->cmd_count = argc - 4; px->infile = handle_here_doc(px->limiter); px->outfile = open(argv[argc - 1], O_WRONLY | O_CREAT | O_APPEND, 0644); /* >> */ } else { px->cmd_count = argc - 2; px->infile = open(argv[1], O_RDONLY); px->outfile = open(argv[argc - 1], O_WRONLY | O_CREAT | O_TRUNC, 0644); /* > */ } if (px->outfile < 0) error_exit(argv[argc - 1]); }
| Mode | Flag open | Effet | Équivalent shell |
|---|---|---|---|
| Normal | O_TRUNC | Écrase le contenu existant | > outfile |
| here_doc | O_APPEND | Ajoute à la fin | >> outfile |
< file ouvre un fichier existant et l'utilise comme STDIN. C'est
ce que fait pipex en mode normal. << EOF crée une entrée
à la volée en lisant stdin jusqu'à un délimiteur. C'est ce que fait pipex en
mode here_doc. La sortie est la même pour la commande (un fd de lecture), mais la
source est radicalement différente.
Gestion d'erreurs
Un bon pipex doit se comporter comme le shell face aux erreurs : afficher un message clair, libérer ses ressources, et sortir avec un exit code conventionnel. La règle d'or : ne jamais crasher, même sur une entrée absurde.
Cas d'erreurs typiques
open(infile, O_RDONLY) retourne -1. La convention shell : afficher
un message et continuer (cmd1 n'aura rien à lire, cmd2 recevra EOF). Pipex utilise
perror et met infile = -1 pour ne pas crasher
dans run_child.
open(outfile, O_WRONLY|O_CREAT) peut échouer (permission, chemin
invalide). Pipex appelle error_exit(argv[argc-1]) qui
perror + exit(1).
find_path ne trouve rien dans PATH et retourne le nom brut.
execve échoue, perror affiche « ls: No such
file or directory », le child appelle exit(127). Code 127 =
convention POSIX pour « command not found ».
execve échoue avec
EACCES. perror affiche « Permission
denied ». Le child exit avec 126 (convention POSIX).
argc < 5 : pipex affiche un usage sur STDERR et retourne 1.
C'est géré dès le début de main.
error_exit("fork") ou error_exit("pipe"),
qui perror + exit(1).
Table des exit codes
| Code | Signification | Quand | Source |
|---|---|---|---|
0 | Succès | Toutes les commandes terminent normalement | return final de main |
1 | Erreur générique | open/fork/pipe échoue, error_exit | error_exit → exit(1) |
126 | Commande trouvée mais non exécutable | execve retourne EACCES | (bonus, pas dans l'implé de base) |
127 | Commande introuvable | execve retourne ENOENT | execute_cmd → exit(127) |
128 + N | Terminé par signal N | Child tué par SIGSEGV, SIGPIPE... | Kernel, pas pipex |
C'est une convention POSIX ancienne : le shell utilise 127 pour « command not
found » et 126 pour « command found but not executable ». Si votre pipex ne fait
pas la distinction, le testeur peut s'en plaindre. L'implémentation actuelle retourne
toujours 127 en cas d'échec d'execve, ce qui couvre les deux cas mais
n'est pas strictement POSIX-compliant.
Pas de zombie processes
Un zombie est un processus terminé mais toujours présent dans la table des
processus du kernel, parce que son parent n'a pas encore appelé
waitpid. Pour les éviter, pipex appelle
waitpid(-1, NULL, 0) dans une boucle, une fois par child forké.
Si votre boucle fait waitpid une fois au lieu de N fois, vous laissez
N-1 zombies. Visible avec ps aux | grep Z. Le correcteur 42 vérifie ça
systématiquement. La règle : un waitpid par fork.
La fonction error_exit
void error_exit(char *msg) { perror(msg); /* "msg: errno_string" sur stderr */ exit(1); }
perror est pratique parce qu'elle traduit automatiquement
errno en message lisible (« No such file or directory »,
« Permission denied », « Too many open files »...). Mais elle appelle
exit(1) immédiatement, sans libérer la mémoire. Pour les erreurs dans
les children, ce n'est pas grave : le kernel récupère toute la mémoire au exit. Pour
le parent, c'est plus discutable, mais toléré par le sujet.
Une version plus propre distinguerait : (1) erreur fatale du parent → cleanup + exit(1), (2) erreur d'un child → perror + exit(127/126), (3) warning non fatal (infile inexistant) → perror, continuer. L'implémentation actuelle est minimaliste mais suffisante pour passer le sujet.
Compilation & tests
Le Makefile du projet est volontairement simple mais conforme aux exigences 42 :
all, clean, fclean,
re, avec compilation en -Wall -Wextra -Werror.
Le Makefile
NAME = pipex CC = gcc CFLAGS = -Wall -Wextra -Werror SRC_DIR = src OBJ_DIR = obj INC_DIR = includes LIBFT_DIR = libft LIBFT_LIB = $(LIBFT_DIR)/libft.a SRCS = $(SRC_DIR)/pipex.c \ $(SRC_DIR)/execute.c \ $(SRC_DIR)/split.c \ $(SRC_DIR)/here_doc.c \ $(SRC_DIR)/utils.c OBJS = $(SRCS:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o) INC_FLAGS = -I$(INC_DIR) -I$(LIBFT_DIR) LIB_FLAGS = -L$(LIBFT_DIR) -lft all: $(NAME) $(NAME): $(LIBFT_LIB) $(OBJS) $(CC) $(CFLAGS) $(OBJS) $(LIB_FLAGS) -o $(NAME) $(LIBFT_LIB): make -C $(LIBFT_DIR) $(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR) $(CC) $(CFLAGS) $(INC_FLAGS) -c $< -o $@ $(OBJ_DIR): mkdir -p $(OBJ_DIR) clean: rm -rf $(OBJ_DIR) make -C $(LIBFT_DIR) clean 2>/dev/null || true fclean: clean rm -f $(NAME) make -C $(LIBFT_DIR) fclean 2>/dev/null || true re: fclean all .PHONY: all clean fclean re
Commandes usuelles :
$ make # compile pipex + libft $ make re # fclean + all $ make fclean # supprime binaire + objets $ valgrind ./pipex in "ls" "wc" out # check leaks
Tests d'équivalence avec le shell
Le test ultime : diff entre la sortie de pipex et celle du shell.
$ echo "Hello pipex" > infile $ ./pipex infile "cat" "cat -e" out_mine $ < infile cat | cat -e > out_shell $ diff out_mine out_shell && echo "OK ✓" $ ./pipex infile "ls -l" "wc -l" out_mine $ < infile ls -l | wc -l > out_shell $ diff out_mine out_shell && echo "OK ✓" $ ./pipex infile "grep pipex" "wc -w" out_mine $ < infile grep pipex | wc -w > out_shell $ diff out_mine out_shell && echo "OK ✓"
$ ./pipex here_doc EOF "cat" "cat -e" out_mine heredoc> line one heredoc> line two heredoc> EOF $ cat << EOF | cat -e >> out_shell line one line two EOF $ diff out_mine out_shell && echo "OK ✓" $ ./pipex infile "cat" "grep a" "wc -l" "cat -e" out_mine $ < infile cat | grep a | wc -l | cat -e > out_shell $ diff out_mine out_shell && echo "OK ✓"
Edge cases à tester absolument
./pipex nope "cat" "wc" out → doit afficher « nope: No such file »
sur stderr et produire un outfile vide (comme le shell).
./pipex in "cmd_qui_existe_pas" "cat" out → « cmd_qui_existe_pas:
No such file or directory », exit 127.
./pipex in "/bin/ls" "cat" out → doit fonctionner sans passer par
PATH (test cmd[0] == '/' dans find_path).
./pipex in "./pipex" "cat" out → doit fonctionner (test
cmd[0] == '.' dans find_path).
./pipex in "echo 'hello world'" "cat" out → doit préserver
« hello world » comme un seul argument.
touch empty; ./pipex empty "cat" "wc -c" out → out doit contenir
« 0\n » (0 octet en entrée).
touch ro; chmod 000 ro; ./pipex in "cat" "cat" ro → doit afficher
« ro: Permission denied » et exit 1.
./pipex here_doc EOF "cat" "cat" out → out doit
contenir les deux contenus concaténés (mode APPEND).
Erreurs courantes
| Symptôme | Cause | Fix |
|---|---|---|
| pipex se fige après cmd1 | fd de pipe non fermé dans un child | Vérifier close_all_pipes dans run_child |
| pipex se fige en fin d'exécution | Parent a gardé une extrémité de pipe ouverte | close_all_pipes dans le parent aussi |
| here_doc ne termine pas | Oubli du close(pipe_fd[WRITE_END]) | Toujours fermer WRITE_END après la boucle |
| Commande introuvable alors qu'elle existe | PATH non trouvé ou mal splitté | Vérifier get_path_env et le +5 |
| Zombie processes visibles | waitpid pas appelé assez de fois | Une boucle waitpid par fork |
| Fuite mémoire sur cmd non trouvée | full et tmp non libérés dans find_path | free(tmp) et free(full) à chaque itération |
| Outfile écrasé alors qu'il devrait être appendé | Mauvais flag dans init_pipex | O_APPEND si here_doc, O_TRUNC sinon |
| Quotes cassées | Utilisation de ft_strsplit au lieu de split_cmd | Brancher split_cmd dans execute_cmd |
1. Implémentez le cas mandatory (2 commandes) en dur. Faites passer
diff avec < in cmd1 | cmd2 > out. 2. Généralisez à N
commandes — c'est juste une boucle. 3. Ajoutez le here_doc. 4. Testez les edge cases
un par un. Valgrind après chaque étape — c'est la seule façon de
détecter les fuites dans les children avant que le testeur ne les voie.
Si votre pipex passe le pipex-tester de gmarcha et la
moulinette-style du correcteur, vous avez gagné. La moulinette teste la conformité
byte-à-byte avec le shell sur des dizaines de cas — y compris les here_doc, les
multi-pipes, et les erreurs. Il n'y a pas de raccourci : il faut que chaque appel
système soit à sa place, chaque fd soit fermé, chaque exit code soit correct. Bon
courage, androïde.