Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR-161 — Dépendances, ordre et « prêt » dans air-launchd (temps 3b)

Statut : Proposé (2026-08-15). Décide comment air-launchd ordonne le démarrage de plusieurs services, ce que « prêt » veut dire, ce qu’il advient d’un service dont une dépendance meurt, et dans quel ordre tout s’arrête.

Catégorie : Supervision, couche 2. Prolonge ADR-157 (lanceur multi-service) et s’appuie sur ADR-150 (octroi et arbitrage) et ADR-010 (capability = fd, registre de noms).

Contexte

Ce qui est déjà là — mesuré le 2026-08-15, pas relu dans des notes

Le lanceur sait déjà, en production :

CapacitéÉtat
balayage de /var/lib/air/services/, N services sur un seul ppollsupervise.rsfait (temps 3a)
mise en cage dérivée de l’octroi, appliquée avant execvesupervise.rsfait (temps 2)
cgroup par service, déménagement du superviseur, délégation des contrôleursAirCgroupDelegationfait
pids.max et memory.high posés sans être demandés, unlimited explicitecli.rs, supervise.rsfait
mise à mort fiable de l’arbre par cgroup.killAirCgroup::kill_allfait
registre de noms AirCom hébergé par le lanceurtemps 2b-iifait
restart / maxRestarts au schéma du manifesteServiceStartupschéma posé, politique = temps 3c

Correction d’une affirmation fausse. La note notes/air-launchd-couche2-roadmap-fr.md écrivait « aucun AirCgroupManager n’existe ; seul air-sys-syscall connaît le mot ». Il existe — crates/air-cgroup, avec delegate, enable_for_children, set_process_limit, set_memory_high, kill_all — et air-launchd l’utilise. La note est corrigée dans la même PR que cet ADR. Le bornage cgroup du §4.5.1 de la spec est donc FAIT, contrairement à ce que la roadmap annonçait.

Ce qui manque, et c’est tout ce dont traite cet ADR

Rien, dans air-launchd ni dans air-launch-proto, ne connaît les mots dépendance, ordre, prêt ou arrêt ordonné — vérifié par balayage. Aujourd’hui les N services démarrent tous, sans ordre, et le sort de l’un est indifférent à celui des autres.

C’est tenable pour un service isolé — air-sshd aujourd’hui. Cela cesse de l’être dès le deuxième : un service qui parle à un autre par AirCom démarre avant que son interlocuteur ait publié son nom, échoue, et sa politique de relance transforme un défaut d’ordonnancement en boucle de redémarrage. Le défaut se présenterait comme un problème du service, alors qu’il appartient au superviseur.

Ce qu’on refuse d’imiter

systemd distingue Requires=, Wants=, BindsTo=, PartOf=, After=, Before=, Requisite=. Six relations dont la combinatoire est la première source d’unités qui ne font pas ce que leur auteur croyait. Air a un principe qui s’y oppose frontalement — entrée stricte (Principe 11) : la tolérance est la faille, et une relation dont la sémantique se devine par croisement de deux champs est une tolérance.

Décisions

D1 — La dépendance se déclare au manifeste, et l’octroi l’arbitre

Un champ est ajouté à ServiceStartup :

  # Les services dont celui-ci a besoin, par identifiant de bundle.
  requires @4 :List(Text);

Le développeur demande, l’administrateur arbitre. Ce n’est pas une formalité : déclarer requires donne au service le pouvoir de faire démarrer un autre service. Un pouvoir qui échapperait à l’octroi serait un second chemin vers le lancement, c’est-à-dire le contournement du modèle entier.

Par quel mécanisme, exactement (précision du 2026-08-20, arbitrage BDFL). La première rédaction disait « exactement comme pour un accès fichier ou un nom AirCom ». C’était inexact, et l’inexactitude comptait : un accès fichier vit dans Entitlements, où l’invariant d’ADR-150 D1 (Grant.granted ⊆ AppManifest.requested) est vérifié mécaniquement. ServiceStartup est le champ @7 du manifeste, hors de requested : y placer requires le place hors de cet invariant.

L’arbitrage retenu ne passe donc pas par une sixième famille d’Entitlements, mais par l’empreinte du manifeste : Grant.manifestSha256 lie déjà l’octroi à ce manifeste exactement, donc à ces requires. air-bundle show les affiche, grant les entérine en bloc. Conséquence assumée, qu’il faut dire plutôt que taire : l’arbitrage est tout-ou-rien — l’administrateur ne peut pas accorder un service en lui refusant une de ses dépendances. Il accorde le manifeste tel qu’il est, ou il ne l’accorde pas. En contrepartie, Entitlements — surface très contractuelle — n’est pas ouverte pour ce besoin.

Ce que « l’octroi arbitre » veut donc dire ici : l’administrateur a vu et signé, pas « la machine vérifie un sous-ensemble ». La distinction est réelle ; l’écrire évite qu’un lecteur prête à ce champ une garantie mécanique qu’il n’a pas.

L’ajout est un champ Cap’n Proto nouveau : un manifeste antérieur reste lisible, et son requires vaut liste vide — le comportement d’aujourd’hui. La compatibilité est donc obtenue par la discipline du schéma, pas par une tolérance du lecteur.

D2 — « Prêt » a deux sens, tous deux observables par le lanceur

enum Readiness {
  # Prêt dès que l'`execve` a réussi. Ordinal 0 : c'est ce que vaut un champ NON POSÉ
  # sur le fil, et c'est le comportement actuel — le défaut du schéma coïncide avec
  # l'existant, jamais avec une intention nouvelle.
  exec @0;
  # Prêt quand le service a PUBLIÉ son nom dans le registre AirCom hébergé par le
  # lanceur (temps 2b-ii).
  published @1;
}

published est le sens fort, et il ne coûte aucun protocole nouveau : le lanceur héberge déjà le registre de noms, il voit donc la publication se produire. Là où systemd a dû inventer sd_notify — un canal dédié, hors bande, que le service doit apprendre —, Air observe un fait qu’il détient déjà. Un service prêt est un service joignable ; c’est une définition plus utile que « son execve a rendu 0 ».

RECTIFICATION — un canal de « prêt » existait déjà, et il est retiré

(2026-08-20, arbitrage BDFL. Cet ADR a été rédigé le 2026-08-15 sur une prémisse fausse à propos du code ; la rectification s’écrit ici plutôt que d’être passée sous silence.)

Les Alternatives rejetées ci-dessous écartaient « sd_notify ou un canal de readiness dédié » au motif qu’ajouter un canal ferait annoncer par un second chemin un fait que le premier établit. Ce canal existait déjà, codé et spécifié :

Élément
drapeau EXPECT_READY du Hello (bit 0)crates/air-launch-proto/src/lib.rs
message ToLauncher::Readycrates/air-launch-proto/src/lib.rs
« l’équivalent Air de Type=notify »docs/specs/layer-2/air-launchd.md §canal
AirLaunchdHost::expects_ready()crates/air-service/src/launchd.rs

Ce que le lanceur en faisait : Report::Ready était imprimé et publié sur le bus. Rien ne s’y adossait — aucun état « disponible », aucune attente, aucune décision. Et discovery.rs fixait expect_ready: false en dur pour tout service de bundle : le drapeau était inatteignable depuis un manifeste, et n’était donc joignable que par le chemin CLI mono-service.

Décision : EXPECT_READY et Ready sont retirés du canal de lancement, et Readiness reste à deux valeurs. Il n’y a qu’un seul sens de « prêt », et il est observé par le lanceur, jamais annoncé par le service. Garder les deux aurait installé exactement le second chemin qu’ADR-149 D10 proscrit — et que cet ADR croyait écarter.

Le retrait a un prix, qu’il faut nommer : un service qui ne publie aucun nom AirCom ne peut plus déclarer sa disponibilité autrement que par exec. C’est cohérent avec la définition retenue — « un service prêt est un service joignable » —, et sans nom, « joignable » ne veut rien dire.

Séquencement : le canal n’est retiré qu’au moment où published est mis en œuvre, jamais avant. Retirer d’abord laisserait une fenêtre où aucun des deux sens forts n’existe.

La phrase de la spec « l’équivalent Air de Type=notify » sur-vendait par ailleurs : Type=notify conditionne le démarrage des unités dépendantes, alors qu’ici rien n’était conditionné. Elle est corrigée dans la même campagne.

Contrainte de stricte entrée : published exige que le manifeste déclare exactement un nom en publish (ServiceRule.publish). Zéro nom, ou deux, est refusé au chargement — pas deviné. Sans cette règle, le lanceur devrait choisir lequel des noms compte, et deux lecteurs divergeraient.

D3 — Une seule relation, qui porte à la fois l’ordre et la vie

requires signifie les deux : B démarre et devient prêt avant A, et A n’a de sens que tant que B vit. Il n’y a pas de After= sans Requires=, pas de Wants= faible.

Conséquence assumée : on ne sait pas exprimer « démarre après B si B existe, sinon tant pis ». C’est délibéré. Cette expression est précisément celle dont on ne sait pas dire, six mois plus tard, ce qu’elle garantissait. Si le besoin se présente pour de bon, il fera l’objet d’un ADR qui le nommera — pas d’un champ ajouté par commodité.

L’ordre de démarrage est le tri topologique du graphe requires. Deux services sans relation démarrent en parallèle : l’ordre n’est pas une file, c’est un graphe.

D4 — Une dépendance qui meurt arrête ses dépendants

Si B s’arrête — proprement ou non — tout A qui requires B est arrêté, dans l’ordre topologique inverse (les dépendants d’abord), par cgroup.kill : la frontière que le noyau garantit, qu’aucun double fork ne franchit.

L’alternative — laisser A tourner — a été écartée : A a déclaré qu’il avait besoin de B. Le laisser vivre sur une hypothèse fausse produit un service qui semble fonctionner. C’est la forme, en supervision, du défaut que ce projet a déjà payé onze fois : un état qui affirme une propriété qu’il n’a pas.

Le prix est réel et il faut le dire : un service feuille instable emporte ses dépendants. Ce qui rend ce prix acceptable est la politique de relance — et elle relève du temps 3c (§4.6 de la spec), pas de cet ADR. Cet ADR décide de l’arrêt ; 3c décidera du retour.

D5 — L’arrêt général est l’ordre topologique inverse, avec un délai borné

À l’arrêt du lanceur, les services s’arrêtent en ordre inverse du démarrage. Chaque service reçoit SIGTERM sur son groupe, puis, au terme d’un délai borné, cgroup.kill. Un service qui refuse de partir ne peut pas retenir la machine : il retarde d’un délai connu, jamais indéfiniment.

D6 — Un service qui ne devient jamais prêt est un échec, pas une attente

Un service dont le published n’arrive pas dans le délai imparti est déclaré en échec ; ses dépendants ne démarrent pas ; le lanceur le dit, nommément, sur son bus d’événements.

Le délai est posé sans être demandé, comme pids.max et memory.high (décision BDFL du 2026-08-09) : un service sans borne de démarrage est une attente sans fin déguisée en démarrage.

Valeur provisoire : 30 s, et elle est explicitement non mesurée. Aucune mesure ne la fonde aujourd’hui — l’écrire est préférable à laisser croire qu’un chiffre rond est un résultat. Conformément à la directive « aucune version publique sans bornes », cette valeur doit être mesurée — temps de publication observé d’air-sshd sur la machine la plus modeste du parc, le Raspberry Pi 4 — avant que le lanceur soit présenté comme utilisable par des tiers.

D7 — Ce qui est refusé au chargement, franchement et tôt

Au balayage, avant qu’aucun service ne démarre :

  1. un cycle dans le graphe requires — refusé, le cycle étant nommé dans le message ;
  2. une dépendance sur soi-même ;
  3. une dépendance sur un bundle non installé ou non octroyé ;
  4. published sans exactement un nom publish (D2).

Portée du refus (précision du 2026-08-20, arbitrage BDFL). Le refus est par service, et transitif — jamais global.

La première rédaction ajoutait ici « Aucun de ces cas ne dégrade en on démarre quand même ce qu’on peut. Un graphe qu’on ne sait pas ordonner ne se démarre pas à moitié. » Lu à la lettre, cela voulait dire qu’un seul manifeste fautif déposé dans /var/lib/air/services/ empêche la machine de démarrer air-sshd — c’est-à-dire qu’une erreur d’administration sur un bundle sans rapport coupe l’accès à la machine. Le remède serait pire que le mal.

Cette lecture contredisait en outre le lanceur tel qu’il est déjà : discovery::Skipped pratique le démarrage partiel et l’assume, notamment avec NoUsableGrant — un bundle sans octroi est écarté, nommé à l’administrateur, et le reste démarre.

Donc : chacun des quatre cas produit un Skipped portant son motif, pour le service fautif ; et tout service qui requires un service écarté est écarté à son tour, par fermeture transitive. Un cycle écarte les services qui le composent — l’ADR exige déjà que le cycle soit nommé dans le message, donc les coupables sont désignables sans arbitraire — et leurs dépendants.

Ce que la transitivité préserve est précisément ce que D4 protège : aucun service ne tourne sur l’hypothèse fausse qu’une dépendance est là. Ce à quoi on renonce est de faire tomber tout le système pour la faute d’un seul bundle.

Ce que cet ADR ne décide pas

  • La politique de relance (temps 3c, §4.6) : restart/maxRestarts sont au schéma, leur sémantique reste à trancher — en particulier le retour d’un dépendant après le retour de sa dépendance.
  • L’activation par socket et le lancement à la demande : ils changeraient le sens de « prêt » et méritent leur propre ADR.
  • Les dépendances inter-machines. Hors périmètre.
  • L’expression des bornes en fraction de la capacité (déjà notée comme reste sans blocage au §4.5.1 de la spec).

Conséquences

  • Le manifeste gagne deux éléments de schéma (requires, Readiness) ; les manifestes existants restent valides et se comportent comme aujourd’hui.
  • Le lanceur gagne un séquenceur piloté par son ppoll unique : la mise en attente d’un dépendant est un état, jamais un blocage de la boucle.
  • air-bundle show doit montrer les dépendances demandées, et grant les arbitrer : une dépendance non montrée serait un pouvoir accordé sans être vu.
  • Les tests devront couvrir le graphe, pas seulement le service : cycle refusé, démarrage parallèle de deux branches indépendantes, mort d’une dépendance, arrêt inverse, dépassement du délai de « prêt ».

Alternatives rejetées

  • Copier les six relations de systemd. Rejetée : leur combinatoire est la première source d’unités qui ne font pas ce que leur auteur croyait, et elle contredit le Principe 11.
  • sd_notify ou un canal de readiness dédié. Rejetée : le lanceur héberge déjà le registre de noms. Ajouter un canal reviendrait à faire annoncer par un second chemin un fait que le premier chemin établit — un second chemin au sens d’ADR-149 D10. ⚠️ Cette alternative était écrite comme si le canal n’existait pas — il existait. Voir la rectification en D2 : EXPECT_READY et Ready étaient codés, spécifiés, inertes et inatteignables depuis un manifeste ; ils sont retirés, et non pas seulement « non ajoutés ».
  • Laisser tourner un dépendant dont la dépendance est morte. Rejetée en D4 : c’est un service qui affirme une propriété qu’il n’a plus.
  • Déclarer les dépendances hors du manifeste (fichier d’administration séparé). Rejetée : la dépendance est un pouvoir ; hors du manifeste, elle échappe à la signature du développeur et à l’arbitrage de l’octroi.
  • Attendre indéfiniment qu’un service devienne prêt. Rejetée en D6 : une attente sans borne se présente comme un démarrage en cours, indéfiniment.

Licence du document : MPL 2.0