Programmation Avancée

TP9: un chat dans une monade

Aujourd'hui, nous allons construire une petite application de messagerie instantanée pair-à-pair, en utilisant la bibliothèque de threads coopératifs Lwt.

Avant de commencer, ouvrez le manuel lwt et récupérez le Makefile qui vous permettra de faire make foo pour construire un binaire à partir d'un unique fichier foo.ml utilisant les modules et l'extension de syntaxe de Lwt.

Partie 1: se familiariser avec lwt

Les threads coopératifs lwt forment une monade, dont les opérations de base sont Lwt.bind et Lwt.return. Dans le modèle lwt, un thread s'exécute potentiellement dès qu'il est créé. Cependant, il y a quand même une fonction Lwt_main.run qui sert à activer le scheduler lwt jusqu'à ce que le thread passé en argument se termine.

Sur ce noyau, on a des opérations comme Lwt.join pour exécuter deux threads en parallèle. La bibliothèque fournit aussi des tâches primitives, notamment Lwt_io.printf, version non-bloquante / coopérative de printf. (Toutes ces fonctions sont documentées dans le manuel, et je vous invite à ouvrir les pages correspondant à ces modules.)

Dans le fichier hello.ml définir une fonction repete : string -> int -> unit Lwt.t telle que repete s n affiche n lignes numérotées et contenant le message s, avec un délai aléatoire de moins d'une seconde entre chaque ligne.

Exécuter en parallèle deux instances de repete pour dire bonjour dans deux langues de votre choix. Votre application ne devra pas terminer avant que les deux threads aient effectué tous leurs outputs.

Réécrire votre programme en utilisant l'extension syntaxique documentée brièvement sur la première page du manuel lwt. On utilisera notamment le notation lwt x = .. in .. pour le bind, >> pour la séquence, et éventuellement un petit for_lwt.

Partie 2: un jeu palpitant

Les threads lwt peuvent (en général) être annulés, c'est à dire interrompus. Cela peut être fait via la fonction Lwt.cancel explicitement, mais aussi implicitement par exemple avec la fonction pick qui, comme join, exécute plusieurs tâches en parallèle, mais dès qu'une tâche termine annule les autres.

Ecrire une fonction timeout : delay:float -> kill:(unit -> 'a t) -> 'a t -> 'a t qui se comporte comme la tâche passée en argument si celle-ci termine avant de délai imposé, et sinon se comporte comme la tâche kill.

Tester avec le jeu suivant (sûrement une super idée de jeu pour téléphone mobile) où l'utilisateur doit répétitivement entrer une ligne avant un timeout d'une seconde:

let kill () =
  Lwt_io.printf "Game over!\n" >>
  Lwt.fail (Failure "over")

let rec f () =
  Lwt_io.printf "Vite, une ligne!\n> " >>
  lwt l = timeout 1. ~kill (Lwt_io.read_line Lwt_io.stdin) in
  Lwt_io.printf "Merci pour ces %d caractères...\n\n" (String.length l) >>
  f ()

let () = Lwt_main.run (f ())

Partie 3: interface utilisateur

Notre application de messagerie instantanée aura une interface très primitive en mode texte. Cette interface permettra à l'utilisateur de saisir des messages, et affichera les messages des autres utilisateurs. Malheureusement, ces deux tâches sont concurrentes, et l'affichage d'une nouvelle ligne au milieu d'une saisie a un effet assez moche. Récupérer la base io_messy.ml pour le vérifier.

Votre mission est de modifier ce fichier pour instaurer un fonctionnement plus propre. La dernière ligne devra toujours afficher la saisie en cours, et une saisie partielle ne devra s'afficher que là. Pour cela on maintiendra un Buffer contenant la saisie en cours, on effacera la saisie en cours avant l'affichage d'une nouvelle ligne, et on la restaurera ensuite. On utilisera les fonctions Lwt_term.clear_line et Lwt_term.goto_beginning_of_line (doc dans /usr/lib/ocaml/lwt/lwt_term.mli, car c'est cassé en ligne...) ainsi que Lwt_term.read_key pour capturer en temps-réel les évènements clavier de l'utilisateur.

L'utilisation de read_key nous prive de fonctionalités usuelles du shell, notamment les réactions à Ctrl-Z, Ctrl-C et l'effacement que nous allons simuler un minimum:

lwt key = Lwt_term.read_key () in
if key = Lwt_term.key_enter then
  (* Saisie complète, à valider. *)
  ...
else
  match key with
    | Lwt_term.Key s ->
        (* Nouveau caractère à prendre en compte. *)
        ...
    | Lwt_term.Key_control '?' ->
        (* Effacement *)
        Buffer.clear buffer ;
        ...
    | Lwt_term.Key_control 'c' ->
        Lwt.fail (Failure "byebye")
    | Lwt_term.Key_control 'z' ->
        (* Dodo *)
        Unix.kill 0 Sys.sigstop ;
        ...
    | _ ->
        (* On ignore le reste *)
        ...

Une fois un premier prototype réalisé, re-organisez votre code pour séparer l'interface (fonctionalités de saisie et affichage de lignes) du reste du code (dans le cas d'io_messy, affichage de Tic et Tac et simple affichage des lignes saisies). Par exemple on pourrait extraire la fonctionalité comme une fonction init : (string -> unit Lwt.t) -> (string -> unit Lwt.t) * unit Lwt.t qui prend en argument la fonction de traitement des lignes saisies, et renvoie (1) la fonction à appeler pour afficher proprement une ligne et (2) la tâche de gestion des I/O. L'utilisation de cet exemple devrait vous convaincre que c'est un choix de design un peu pénible et qu'on peut améliorer en utilisant d'autres traits du langage OCaml.

Correction: solution ci-dessus io.ml et version OO io_oo.ml.

Partie 4: client-serveur

Client

Dans un fichier client.ml écrivez un client qui se connecte à un serveur et lui envoie dix lignes de texte avec un délai d'une demi-seconde après chaque ligne. Le client devra se connecter par défaut à 127.0.0.1 sur le port 1234, ou permettre le choix de ces paramètres sur la ligne de commande: ./client <host> <port>.

Les fonctions Unix.gethostbyname et Lwt_io.open_connection vous seront a priori utiles. Un modèle possible est disponible ici.

Serveur

Réalisez ensuite le serveur qui va avec, il doit accepter un nombre arbitraire de connections et les traiter en parallèle: pour chaque client connecté le serveur reçoit des lignes et les affiche sur sa sortie standard. Le serveur devra fonctionner sur le port 1234 par défaut, et autoriser la spécification d'un autre port via la ligne de commande.

Vous pourrez utiliser les fonctions bind, listen, accept du module Lwt_unix. Un modèle possible est disponible ici (à simplifier car dans le modèle les messages sont re-diffusés aux autres clients).

Corrigé: client.ml, server.ml.

Partie 5: un chat pair à pair

Nous allons réaliser une première version de notre messagerie pair à pair. Je donnerai au tableau des indications sur le style conseillé pour écrire le code, en guise de corrigé de la partie 3. Je donnerai aussi une description du fonctionnement attendu de l'application, ainsi que du format des messages, qui seront sérialisé en utilisant notamment Lwt_io.LE.

On pourra utiliser la fonction Lwt_list.iter_p.

Bonus

On veut éviter qu'un pair disparaisse. Mettre en place un système de ping et un timeout pour éliminer les pairs morts. Utiliser le système de ping pour avoir une liste des pairs actifs.

Gérer les échecs de connection au pair initial. Votre client pourrait par exemple re-essayer de se connecter, après un délai.

Afficher des lignes en couleur en fonction du nom d'utilisateur.

Gérer la saisie de longues lignes.

Mettre en place un système de tolérance aux pannes: un pair ne doit pas être connecté au réseau par un unique autre pair. Pour cela il faut changer le protocol pour qu'un pair obtienne l'adresse d'autres pairs et puisse ainsi décider de s'y connecter.