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é | Où | État |
|---|---|---|
balayage de /var/lib/air/services/, N services sur un seul ppoll | supervise.rs | fait (temps 3a) |
mise en cage dérivée de l’octroi, appliquée avant execve | supervise.rs | fait (temps 2) |
| cgroup par service, déménagement du superviseur, délégation des contrôleurs | AirCgroupDelegation | fait |
pids.max et memory.high posés sans être demandés, unlimited explicite | cli.rs, supervise.rs | fait |
mise à mort fiable de l’arbre par cgroup.kill | AirCgroup::kill_all | fait |
| registre de noms AirCom hébergé par le lanceur | temps 2b-ii | fait |
restart / maxRestarts au schéma du manifeste | ServiceStartup | schéma posé, politique = temps 3c |
Correction d’une affirmation fausse. La note
notes/air-launchd-couche2-roadmap-fr.mdécrivait « aucunAirCgroupManagern’existe ; seulair-sys-syscallconnaît le mot ». Il existe —crates/air-cgroup, avecdelegate,enable_for_children,set_process_limit,set_memory_high,kill_all— etair-launchdl’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 | Où |
|---|---|
drapeau EXPECT_READY du Hello (bit 0) | crates/air-launch-proto/src/lib.rs |
message ToLauncher::Ready | crates/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 :
- un cycle dans le graphe
requires— refusé, le cycle étant nommé dans le message ; - une dépendance sur soi-même ;
- une dépendance sur un bundle non installé ou non octroyé ;
publishedsans exactement un nompublish(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/maxRestartssont 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
ppollunique : la mise en attente d’un dépendant est un état, jamais un blocage de la boucle. air-bundle showdoit montrer les dépendances demandées, etgrantles 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_notifyou 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_READYetReadyé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