Deux bugs High trônaient en tête du registre d'audit en conditions réelles de sh0 au matin du
10 septembre 2026. Le soir, les deux étaient clos, et clos de la seule manière que ce projet accepte :
par une observation datée sur une vraie machine, avant et après, mêmes applications, même sonde.
Voici ce qu'il a fallu.
Premier bug : un autoscaler qui ne décidait jamais
L'autoscaler de sh0 se réveille toutes les 30 secondes, lit les deux dernières minutes d'échantillons CPU et mémoire de chaque application dotée d'une politique de scaling, et ajuste le nombre de réplicas à la hausse ou à la baisse. Sur la machine de démo, il tournait depuis des semaines. Il n'avait jamais rien mis à l'échelle.
La table des métriques renseigne recorded_at par une valeur par défaut de colonne :
sqlrecorded_at TEXT DEFAULT (datetime('now')) -- 2026-09-10 00:43:04L'autoscaler construisait la borne de sa fenêtre en Rust :
rustlet since = (Utc::now() - Duration::seconds(120)).to_rfc3339(); // 2026-09-10T00:41:28+00:00Puis la requête disait recorded_at >= ?. La colonne est de type TEXT, donc SQLite compare des
chaînes. À l'index 10, les lignes stockées portent une espace (0x20) et la borne porte un T
(0x54). Toutes les lignes de la journée se classent sous la borne. Zéro ligne, toujours, pour
chaque application. should_scale_up et should_scale_down étaient tous deux faux en permanence.
Le plus savoureux : le champ juste à côté, last_scale_at, était en RFC3339 des deux côtés et
fonctionnait parfaitement. C'est ainsi que le cooldown avait été prouvé en conditions réelles la
veille, alors que ce qu'il était censé temporiser ne s'était jamais déclenché une seule fois.
Le correctif n'est volontairement pas une migration. Chaque machine installée écrit la colonne par la valeur par défaut SQL ; c'est donc la borne qui s'aligne sur la forme stockée, par un point de construction unique :
rustpub fn bound_seconds_ago(secs: i64) -> String {
(Utc::now() - Duration::seconds(secs)).format("%Y-%m-%d %H:%M:%S").to_string()
}Le test unitaire insère une ligne par la valeur par défaut et la relit avec cette borne. Il épingle
aussi le défaut : une borne faite des mêmes chiffres avec un T à la place de l'espace ne doit rien
trouver. Le premier jet de cette assertion utilisait now - 120s et aurait été instable pendant deux
minutes après chaque minuit UTC, parce que la partie date change en premier. L'audit adverse l'a
repéré.
Deuxième bug : cinquante secondes de 502 à chaque redéploiement
Le second bug avait déjà été « corrigé » une fois. Le chemin public *.sh0.app passe par un petit
reverse proxy interne à sh0 qui met en cache domain -> ip:port pendant 60 secondes. Un
redéploiement remplace le conteneur, donc l'IP change, donc le cache doit être purgé. Le correctif du
6 septembre a ajouté une purge. Le rejeu du 9 septembre a mesuré exactement la même coupure de
50 secondes.
Le diagnostic issu de ce rejeu était juste, et il portait sur l'ordre, pas sur les clés. La purge
s'exécutait dans route_container, qui tourne avant l'arrêt de l'ancien conteneur et avant que
finalize_app n'écrive le nouveau container_id en base. Toute requête arrivant entre les deux se
résolvait de nouveau via la base, trouvait l'ancien conteneur et remettait en cache son upstream
condamné avec un TTL neuf de 60 secondes. La purge ne pouvait fonctionner que si personne ne visitait
l'application pendant le déploiement, c'est-à-dire dans le seul cas où le bug n'avait pas de victime.
La lecture du code a ajouté un détail que le correctif du 6 avait manqué : le chemin git, celui
qu'emprunte chaque déploiement par git push, n'appelait jamais route_container. Il routait en
ligne. Il n'avait jamais rien purgé.
Le correctif place l'écriture en base avant la purge, et les deux avant l'arrêt, sur les cinq chemins
de déploiement ; un test d'ordre du source lit pipeline.rs et refuse tout chemin qui inverse cet
ordre. Le proxy reçoit une défense pour les remplacements que le pipeline ne voit jamais : un upstream
en cache qui refuse la connexion est évincé et résolu de nouveau une fois ; un upstream qui n'a pas
répondu n'est jamais mis en cache ; un timeout n'est jamais retenté, parce qu'un POST parti en timeout
a pu être reçu.
La preuve, et l'application qui refusait de reproduire le bug
La règle, ici, veut que « corrigé dans le code » ne soit pas un état. Une fiche se clôt sur une observation avec la commande et sa sortie, ou elle reste ouverte.
La matinée a été consacrée aux mesures de référence sur la machine en v1.7.1. Pour le bug du proxy,
la première tentative a utilisé traefik/whoami : trente sondes, trente 200, pas de bug. Même
binaire que celui du rejeu de la veille. La différence tenait au conteneur : whoami traite SIGTERM
instantanément, donc la fenêtre entre la purge et la mort durait quelques millisecondes, et une sonde
toutes les trois secondes n'y tombait jamais. L'application Go du rejeu ignorait SIGTERM, et
docker stop attendait ses trente secondes complètes. Le bug exige un conteneur qui meurt lentement.
Avec le dépôt exact du 9, la référence a donné 000, 000, 502, 502, 502 de +12s à +55s. Un
http.server Python tournant en PID 1 a donné deux 000. Ce sont les deux applications sur
lesquelles l'après a été mesuré.
Pour l'autoscaler : trois réplicas nginx au repos, une politique de 1 à 3, et un relevé toutes les
30 secondes. Sous v1.7.1 : 36 échantillons dans la fenêtre stockée, trois réplicas pendant quatre
minutes et demie, zéro décision.
Puis la release candidate. L'agent de vérification a lancé la batterie complète (975 tests), le tag a été poussé, GitHub a mis deux heures à traiter le build, et l'installation épinglée a atterri à 09:06:56 UTC.
09:07:01 INFO Autoscaling down current_replicas=3 target_replicas=2
09:08:31 INFO Autoscaling down current_replicas=2 target_replicas=1Premier tick après le redémarrage. Les sondes du proxy : trente 200 sur le chemin git, trente 200
sur le chemin Dockerfile, et pour la défense, docker stop a donné un 404 en cinq secondes,
docker start a donné 200 en trois millisecondes, sans attendre le TTL.
Ce que la campagne a trouvé sans que personne ne le cherche
La contre-preuve de l'autoscaler devait être le scale-up sous charge. Quarante boucles curl
parallèles pendant 150 secondes ont fait sortir 106 Mo du conteneur. La métrique CPU est restée à
0.0 sur les 22 échantillons. Le scale-down fonctionne parce que zéro est toujours sous le seuil. Le
scale-up sur CPU ne peut jamais se déclencher. C'est une nouvelle fiche Medium, avec le fichier à
examiner et l'observation qui la clôturera, et elle ne rouvre pas celle qui vient d'être prouvée : la
preuve due était la descente jusqu'à min_replicas, et elle a eu lieu.
Les mécanismes qui ont fait qu'une journée a suffi
- La session de décision du matin a confronté son propre prompt au code avant d'y croire et a trouvé
- six dérives, dont l'une était que le prompt classait comme dette acceptable un bug bloquant la
- chaîne d'outils.
- L'audit adverse a tourné avant le commit, dans un agent en lecture seule, et a rendu cinq
- correctifs. Quatre ont été appliqués avant même que le compilateur ne tourne.
- La vérification a tourné dans un agent d'arrière-plan dont la sortie n'est jamais entrée dans le
- contexte principal.
- La version a été incrémentée avant le tag de RC, parce que la RC précédente affichait l'ancienne
- chaîne de version sur la machine et que personne ne pouvait dire ce qui tournait.
- La machine a été ramenée à zéro application, et les logs de référence sont dans le dépôt, à côté
- des fiches.
Le registre est passé de 22 fiches ouvertes à 21. Un seul High subsiste, bloqué sur une question
matérielle qui relève du CEO. Le compte à rebours du lancement n'a toujours pas démarré.