Imaginez un fichier de 943 octets. C’est moins qu’un SMS un peu long. Moins que le logo de votre banque. On le lance, on lui envoie ça :

GET / HTTP/1.0

Et il répond :

HTTP/1.0 200 OK
Content-Length: 29

Hello from Verbose over HTTP!

Pas de Node, pas de Python, pas de nginx devant. Pas de runtime, pas de ramasse-miettes, même pas la bibliothèque C. 943 octets, seuls face au réseau.

Et le plus intéressant n’est pas là. Ce binaire a été écrit par un compilateur qui est lui-même écrit en verbose — celui dont on a vu, le mois dernier, qu’il se reproduisait à l’octet près. Le langage ne se contente plus de se compiler lui-même : il produit maintenant le genre de programme qu’on écrit vraiment.

Où on en est dans la série

On a suivi la moitié qui litl’arbre, le tokenizer — puis la moitié qui écritune règle qui émet du code machine — et enfin le point fixe, où le compilateur s’est reproduit lui-même.

Cette fois, on regarde ce qu’il sait fabriquer. Sept PRs (#132 → #138) en six jours.

Servir du HTTP quand on n’a rien

Commençons par l’analogie, avant le moindre terme technique.

Vous ouvrez un cabinet. Pour recevoir des clients, il vous faut quatre choses, dans cet ordre :

  1. Faire installer une ligne téléphonique. Vous n’avez pas encore de numéro, juste un appareil.
  2. Obtenir un numéro — le 18893. Maintenant les gens peuvent vous joindre.
  3. Décrocher le combiné et le poser sur le bureau : vous êtes joignable, les appels peuvent s’empiler dans la file d’attente.
  4. Prendre les appels, un par un. Chacun : vous écoutez, vous répondez, vous raccrochez. Et vous reprenez le suivant.

C’est exactement ce que fait un serveur, avec quatre appels au noyau :

  socket()      →  "installe-moi une ligne"          → renvoie un numéro de fichier (fd)
  setsockopt()  →  "si je raccroche, libère la ligne  (SO_REUSEADDR)
                    tout de suite, ne la garde pas
                    bloquée une minute"
  bind(18893)   →  "mon numéro sera le 18893,         (INADDR_ANY = sur toutes
                    sur toutes les lignes"             les interfaces réseau)
  listen()      →  "je décroche : mets les appels
                    en file d'attente"

  ┌── boucle, à l'infini ─────────────────────────────┐
  │  accept()   →  prendre l'appel suivant            │
  │                (attend s'il n'y en a pas)         │
  │  read()     →  écouter ce que dit le client       │
  │  write()    →  répondre                           │
  │  close()    →  raccrocher CET appel               │
  └──────────── et on recommence ─────────────────────┘

Voilà. Il n’y a rien d’autre dans un serveur HTTP. Tout ce que vous connaissez — Express, Flask, Spring — est une pile de confort au-dessus de ces quatre lignes. Le confort n’est pas inutile, mais il n’est pas nécessaire : c’est pour ça que 943 octets suffisent.

La première tranche (S1) fait précisément ça, et rien de plus : le gestionnaire renvoie une réponse écrite à l’avance, la même pour tout le monde. C’est le plus petit objet qui mérite encore le nom de serveur.

Le pivot : lire ce que le client a dit

Un serveur qui répond toujours la même chose, c’est un répondeur. Pour aller plus loin, il faut comprendre la requête. C’est la tranche S2 — et c’est le pivot de tout l’arc : rien de ce qui suit n’existe sans elle.

Le client envoie une suite d’octets. Bruts. Il faut y découper deux informations :

  Ce qui arrive sur le fil (octets) :

    G  E  T     /  h  e  l  l  o     H  T  T  P  /  1  .  0  \r \n \r \n
    └──┬──┘  ▲  └────┬────┘       ▲
       │     │       │            │
       │   espace    │          espace
       │             │
    méthode        chemin

  Ce que le parseur en extrait, et range dans deux « cases » :

    req.method  →  pointeur vers "GET"     + longueur 3
    req.path    →  pointeur vers "/hello"  + longueur 6

Un détail qui compte : on ne copie rien. On note juste où ça commence et combien ça fait. Les octets restent là où le noyau les a déposés. C’est une adresse et une longueur — deux nombres — pas une chaîne de caractères recopiée ailleurs.

Ces deux cases, req.method et req.path, sont la matière première de tout le reste.

Répondre différemment selon la demande

Tranche S3 : le routage. Une fois qu’on sait ce qui a été demandé, on peut répondre différemment. Puis S4 : construire le corps de la réponse à partir de la requête.

Voici un exemple réel du dépôt (examples/echo_path.verbose), allégé :

rule echo_handler
  input:
    req : HttpRequest
  output:
    resp : HttpResponse
  logic:
    resp = if req.method == "GET"
             then HttpResponse { status: 200, body: concat("got GET on ", req.path) }
           else if req.method == "POST"
             then HttpResponse { status: 200, body: concat("got POST on ", req.path) }
           else
             HttpResponse { status: 404, body: concat("unsupported method ", req.method) }
  proofs:
    purity:
      reads : [req.method, req.path]

service echo_server
  listen:
    protocol    : http_1_0
    port        : 18893
    max_request : 4096
  handler: echo_handler

Déroulons un appel, valeurs concrètes à chaque étape :

  1. Le client envoie   :  "GET /hello HTTP/1.0\r\n\r\n"
  2. Le parseur remplit :  req.method = "GET"   req.path = "/hello"
  3. La règle évalue    :  req.method == "GET"  →  vrai, on prend la 1re branche
  4. concat assemble    :  "got GET on " + "/hello"  →  "got GET on /hello"
  5. Le binaire écrit   :  HTTP/1.0 200 OK
                           Content-Length: 17

                           got GET on /hello

Notez le bloc proofs:. Il déclare : cette règle lit req.method et req.path, et rien d’autre. Ce n’est pas un commentaire — le compilateur le vérifie. Si la logique lisait un troisième champ sans l’avoir déclaré, ça ne compilerait pas. C’est la promesse habituelle du langage : l’auteur déclare, le binaire ne dérive pas.

Et le concat du point 4 mérite une remarque. Jusqu’à S4, le corps de la réponse devait être écrit à l’avance. À partir de S4, il est assemblé au moment de l’appel — et son tampon vit sur la pile de l’itération. La boucle le libère en une instruction avant l’appel suivant, quelle que soit la branche empruntée. Pas d’allocation, pas de libération à gérer, pas de fuite possible.

Le détail qui claque : une ligne de log, un seul appel

Tranches S5a et S5b : le serveur écrit maintenant ses logs d’accès. Et c’est là que se cache la partie la plus instructive.

Un bloc de log ressemble à ça (exemple réel, examples/audit_strict.verbose) :

log:
  append_file "/tmp/audit.jsonl"
    concat("{\"method\":\"", req.method, "\",\"path\":\"", req.path, "\"}\n")
  on_error: abort

La première implémentation faisait la chose évidente : un write() par morceau du concat. L’inspection du binaire compilé le montrait noir sur blanc — quatre écritures successives :

  0x202  open      ← ouvrir le fichier
  0x217  write     ← "{\"method\":\""
  0x23a  write     ← "GET"
  0x24c  write     ← "\",\"path\":\""
  0x26f  write     ← "/hello\"}\n"
  0x279  close     ← fermer

Ça marche. Tant qu’il n’y a qu’un seul client.

Maintenant, deux requêtes arrivent en même temps. Le noyau peut entrelacer leurs écritures — il n’a aucune raison de ne pas le faire :

  Ce qu'on voulait :
    {"method":"GET","path":"/hello"}
    {"method":"POST","path":"/submit"}

  Ce qu'on peut obtenir :
    {"method":"GET","{"method":"POST","path":"/hello"}
    path":"/submit"}

Deux lignes corrompues. Aucune des deux n’est vraie. Et c’est le pire genre de bug : il n’apparaît pas en développement (un seul client), il ne fait pas planter le programme, il n’apparaît sous charge que de temps en temps — et il abîme précisément le fichier qu’on consulte pour comprendre ce qui s’est passé. Un journal d’audit qui ment sous charge est pire que pas de journal du tout.

La correction (PR #138) : remplacer les N write par un seul writev. C’est le même principe qu’une enveloppe. Au lieu d’envoyer quatre feuilles séparément en espérant qu’elles arrivent dans l’ordre, on les met toutes dans une enveloppe et on poste l’enveloppe. Le facteur ne peut plus les mélanger avec celles du voisin.

  AVANT — 4 appels, entrelaçables          APRÈS — 1 appel, atomique

  write(fd, "{\"method\":\"", 11)          iov[0] = ("{\"method\":\"", 11)
  write(fd, "GET",             3)          iov[1] = ("GET",             3)
  write(fd, "\",\"path\":\"",  10)         iov[2] = ("\",\"path\":\"",  10)
  write(fd, "/hello\"}\n",      9)         iov[3] = ("/hello\"}\n",      9)
                                           writev(fd, iov, 4)
  open → write → write → write → write     open → writev → close
       → close

Le tableau iov (quatre paires adresse + longueur) est construit sur la pile, et l’appel système n° 20 le consomme d’un coup. C’est tout ou rien : la ligne entière est écrite, ou rien ne l’est. Jamais la moitié.

Ça semble être un détail d’optimisation. C’en est un — on passe de six appels système à trois. Mais surtout, c’est un changement de garantie : le fichier de log devient une suite de lignes complètes, par construction, quelle que soit la concurrence. Et c’est le prérequis de la tranche suivante, on_error: abort — « si je n’arrive pas à écrire la trace d’audit, j’arrête tout ». Cette promesse-là n’a aucun sens si une ligne peut déjà sortir coupée en deux.

Ce que ça veut dire

Récapitulons les sept tranches :

  S1   un serveur qui répond toujours pareil          → ELF de 943 octets
  S2   parser la requête (méthode, chemin)            → le pivot
  S3   router selon la méthode et le chemin
  S4   construire le corps depuis la requête
  S5a  écrire des lignes de log
  S5b  des logs qui contiennent les champs de requête
  5b.5 une ligne = un writev = atomique

En six jours, le compilateur auto-hébergé est passé de « il sait se reproduire » à « il sait produire un service web qui parse, route, répond et trace ». Ce sont deux choses très différentes. La première prouve que le langage tient debout ; la seconde, qu’il sert à quelque chose.

Et la limite, nommée comme d’habitude : la v0.10.0 n’est toujours pas taguée. main a 54 commits d’avance sur la v0.9.0 — le point fixe, le bootstrap vérifiant, la tier effets, et maintenant l’arc service. Il y a un moment de release là, mûr depuis des semaines. Il attend une décision, pas du code.

943 octets qui répondent en HTTP. Écrits par un compilateur qui s’est écrit lui-même. Sans runtime, sans libc, et avec des logs qu’on peut croire.