air-launchd — lanceur et superviseur de services (couche 2)
Spec de contrat. Version 1.0 — temps 1 du découpage arrêté dans
notes/air-launchd-couche2-roadmap-fr.md.
Ce que cette spec couvre : le canal de lancement — le contrat par lequel un lanceur active un service Air, lui remet ses écoutes, apprend qu’il est prêt et lui demande de s’arrêter — et le comportement du démon qui l’implémente.
Ce qu’elle ne couvre pas : la mise en cage à l’
exec(temps 2, ADR-150) et la supervision réelle — dépendances, ordre, cgroups (temps 3). Ces deux volets s’ajouteront sans casser ce qui suit : le canal a une version, et une version inconnue est refusée.
1. Le placement, en une phrase
air-launchd est en couche 2 (décision du 2026-08-04) : il lie air-process,
air-socket, air-service — exactement ce que lie déjà air-sshd, le précédent
exact. Depuis la couche 5 où il était rangé à l’origine, c’étaient des sauts de
trois et quatre étages, rendus impossibles par l’abolition de l’entorse
(ADR-149 D1).
2. Le canal de lancement
2.1 Ce que c’est
Un socket AF_UNIX SOCK_SEQPACKET installé sur le descripteur 3 de
l’enfant — juste après stdin/stdout/stderr. C’est l’unique convention
numérique du contrat : tout le reste voyage explicitement.
Le seqpacket préserve les frontières de message. Le Framer du motif sans-IO (ADR-091), c’est donc le noyau : il n’y a ni préfixe de longueur, ni ré-assemblage, donc aucune longueur qui vienne du pair.
2.2 Pourquoi pas des variables d’environnement
systemd annonce son activation en texte — LISTEN_PID=…, LISTEN_FDS=…,
NOTIFY_SOCKET=… : trois parseurs tolérants sur le chemin d’entrée d’une
décision. Le Principe 11
l’interdit (« la tolérance est la faille »). Ici il n’y a aucune variable
d’environnement : un contrôle de présence sur une disposition figée.
Ce n’est pas « binaire donc mieux » — c’est la disposition fixe qui apporte la propriété ; le binaire n’en est que la conséquence.
2.3 Les cinq enregistrements
Entiers petit-boutistes. En-tête commun de 8 octets, puis un corps dont la longueur est imposée par le genre — jamais annoncée.
en-tête magic[4]="AIRL" ‖ version:u16=1 ‖ kind:u16
lanceur → service 1 Hello flags:u32 ‖ reserved[4]=0 (16 o) + SCM_RIGHTS[écoutes…]
2 ⟨RETIRÉ — a porté Ready ; numéro brûlé, jamais réattribué⟩
service → lanceur 3 Stopping — ( 8 o)
service → lanceur 4 Status texte UTF-8 (≤ 240 o) (≤ 248 o)
lanceur → service 5 Stop deadline_ms:u32 ‖ reason:u8 ‖ reserved[3]=0 (16 o)
lanceur → service 6 AirComCapability reserved[8]=0 (16 o) + SCM_RIGHTS[1 fd]
flags du Hello : aucun drapeau n’est défini, et tout bit posé est refusé au
décodage. Le mot demeure sur le fil pour que la disposition du corps ne dépende pas de
l’existence d’un drapeau.
Le genre 2 et le drapeau EXPECT_READY sont retirés (le 2026-09-01, en application de
ADR-161 D2). Ils disaient « le
service annonce qu’il est prêt » ; le sens retenu est l’autre, celui que le lanceur
observe — l’execve a réussi, ou le nom est publié au registre. Garder les deux aurait
installé le second chemin qu’ADR-149 D10 proscrit.
Ce que cette section a affirmé, et qui était faux, reste écrit ici plutôt que passé sous
silence : jusqu’au 2026-08-20 elle présentait ce drapeau comme « l’équivalent Air de
Type=notify ». Il ne l’était pas — Type=notify conditionne le démarrage des unités
dépendantes, alors qu’ici rien n’était conditionné : Report::Ready était imprimé et publié
sur le bus, et aucun état, aucune décision ne s’y adossait. Le drapeau était en outre
inatteignable depuis un manifeste ; seul le chemin CLI mono-service --expect-ready
l’empruntait.
Le numéro 2 reste brûlé, côté canal comme côté bus d’événements (readyRetire @3 dans
air-launch-events.capnp). Le réattribuer ferait lire un fait ancien pour un fait neuf chez
un pair resté sur l’ancienne version — la rupture qui ne se voit pas. Un READY reçu
aujourd’hui est refusé nommément (« enregistrement READY, retiré du protocole »), pas
rangé parmi les genres inconnus.
Le retrait a un prix, déjà nommé par D2 : un service qui ne publie aucun nom AirCom ne peut
plus déclarer sa disponibilité autrement que par exec.
reason du Stop : 1 = arrêt demandé pour ce service, 2 = le superviseur
lui-même s’arrête.
AirComCapability (ajouté le 2026-08-13) ne porte aucune donnée : son corps est
entièrement réservé, et ce qui compte voyage à côté — un descripteur en SCM_RIGHTS,
la connexion au registre de noms. Un corps qui nommerait un service serait une redite : le
service ne choisit pas quel registre il joint, il joint celui qu’on lui remet.
Le corps existe malgré tout, à disposition fixe, pour que l’enregistrement ait la même forme que ses voisins — un genre à géométrie propre ferait un décodeur de plus, donc un endroit de plus où deux lecteurs peuvent diverger.
Il est déposé immédiatement après le Hello, avant le fork — donc il attend déjà quand le
service démarre, exactement comme le Hello. Sur un SEQPACKET l’ordre est celui de l’envoi :
le service lit le Hello, puis celui-ci. À la première place, il est refusé — un
enregistrement hors séquence dit que l’un des deux bouts a perdu le fil, et le deviner serait
la tolérance que le Principe 11 refuse.
Il est remis à tout service, y compris ceux dont l’octroi ne porte aucune règle aircom.
Une remise conditionnelle obligerait le service à deviner s’il doit l’attendre — donc à tolérer
les deux cas, donc à pouvoir se tromper sur ce qu’il vient de recevoir. L’absence de capability
n’est pas une erreur non plus : un service dont le lanceur n’a pas pu en remettre tourne sans
bus, ce qui est un manque de droit, pas une activation douteuse.
Il n’accorde rien. Ce qu’un service a le droit de chercher ou de publier vient de son
octroi (ADR-150, famille aircom), que le registre applique par pid — sur l’identité
que SO_PEERCRED constate, jamais sur celle qu’un demandeur déclare. Tenir le descripteur
permet de demander ; cela ne permet pas d’obtenir.
Ce que l’uniformité coûte, et pourquoi on le paie quand même. Un programme qui lirait son
Hello sans lire cet enregistrement-ci laisserait un message non lu dans la file de son
seqpacket. À sa sortie, le lanceur reçoit alors un ECONNRESET à la place des
enregistrements encore en attente : les derniers mots du service sont perdus, et de façon
intermittente puisque c’est une course. Un service Air n’est pas concerné —
AirLaunchdHost::from_channel consomme les deux à l’activation — et un programme qui ne parle
pas le canal du tout ne lit déjà pas son Hello, donc était déjà dans ce cas. La classe
réellement exposée est celle des programmes qui parlent le contrat à la main : qu’ils lisent
les deux enregistrements de l’activation, et non un seul.
2.4 Les écoutes ne sont pas décrites, elles sont remises
Le Hello ne dit pas combien d’écoutes il porte. Elles arrivent en
SCM_RIGHTS, et leur nombre est celui que le noyau a effectivement remis.
Transmettre ce nombre en plus le rendrait redondant — donc ouvrirait un
désaccord possible entre deux sources, et ajouterait une hypothèse de confiance.
Plafond : 64 écoutes, borné des deux côtés.
Corollaire de sécurité : l’enfant ne reçoit que ce qu’on lui remet. Rien d’ambiant, aucune plage de descripteurs à deviner (POLA, ADR-010).
2.5 Les refus
Un enregistrement est refusé si : magie inconnue, version inconnue, genre inconnu,
sens inversé (un Ready présenté au décodeur du service), longueur ≠ celle que
le genre impose, bit de drapeau inconnu, octet reserved non nul, Status non
UTF-8, trop long, ou porteur d’un caractère de contrôle.
Aucune tolérance : pas de champ ignoré, pas de longueur « au moins », pas de rembourrage accepté. Le sens fait partie du contrat, et il est typé : deux fonctions de décodage distinctes, jamais une seule qui trierait après coup.
Cette promesse engage aussi les pilotes d’I/O, pas seulement le cœur. Le seqpacket
tronque en silence un datagramme trop grand pour le tampon de réception : à la
borne exacte (248), un enregistrement de 300 octets arriverait rogné à 248 et se
décoderait sans broncher — la troncature du noyau serait devenue une tolérance. Les
deux bouts reçoivent donc dans un tampon d’un octet de plus : tout ce qui dépasse
la borne arrive avec 249 octets, et le cœur le refuse. C’est plus simple que de
consulter MSG_TRUNC, et cela ne dépend d’aucun drapeau qu’on pourrait oublier de
lire.
2.6 Il n’existe qu’un encodage par message
Propriété vérifiée par le fuzz (fuzz_air_launch_proto) : tout enregistrement
décodé se ré-encode octet pour octet. Il n’y a donc rien à arbitrer entre deux
lecteurs — la condition pour que « deux implémentations ne divergent pas » soit une
propriété et non un espoir.
3. La détection, côté service
Le service ne lit aucune variable ; il constate, dans cet ordre :
- le descripteur 3 est-il un socket
AF_UNIX/SEQPACKET(SO_DOMAIN/SO_TYPE) ? sinon ⇒ pas activé, et rien n’est touché ; - un message y attend-il déjà ? Le lanceur l’a déposé avant l’
exec, donc il est en file : la lecture est non bloquante (MSG_DONTWAIT) et il n’y a aucune course à arbitrer. Rien en file ⇒ pas activé ; - est-ce un
Helloconforme ? Sinon ⇒ refus — le service échoue au lieu de tourner à moitié activé.
Aux étapes 1 et 2, le descripteur est rendu tel quel (jamais refermé : on ne ferme pas un descripteur dont on n’est pas propriétaire).
air_service::detect() enchaîne : canal de lancement (un fait), puis
environnement systemd (du déclaratif), puis hôte inerte. Un démon Air appelle
detect() et ne connaît que le trait ServiceHost — c’est le seul point du
programme qui sait qu’il existe plusieurs superviseurs.
4. L’arrêt
4.1 Trois temps
| Temps | Moyen | Ce que ça suppose du service |
|---|---|---|
| demander | enregistrement Stop sur le canal | qu’il écoute son canal |
| insister | SIGTERM | qu’il soit un programme Unix ordinaire |
| terminer | SIGKILL | rien |
Le canal est la manière de demander ; le signal reste la manière d’insister. Ce n’est pas une entorse : c’est ce qui rend le lanceur capable d’arrêter un service qui ne parle pas son protocole — un binaire étranger, ou un service Air qui n’a pas armé sa source d’arrêt. Un superviseur qui ne saurait arrêter que ce qui veut bien s’arrêter ne superviserait rien.
4.2 Un superviseur qui disparaît est un ordre d’arrêt
Si le canal se ferme sans ordre, le service en conclut que son superviseur n’est
plus là (ShutdownReason::SupervisorGone) et s’arrête (fail-closed). C’est
sans équivalent systemd : un service socket-activé n’a aucun moyen d’apprendre
que son superviseur est mort. Ici, le canal est le lien — sa fermeture est un
fait, pas une supposition.
Deux façons de se fermer, un seul sens : fermeture propre (lecture de zéro octet) ou
mort avec des données non lues en file (ECONNRESET, cas courant d’un service
qui sort sans jamais lire son Hello).
4.3 Le masque de signaux ne s’hérite pas
Un lanceur bloque SIGTERM/SIGINT pour les consommer sur un signalfd
(ADR-020). Ce blocage est hérité à travers le
fork et l’execve : sans précaution, tout service lancé démarrerait avec
SIGTERM bloqué — donc insensible à l’insistance, arrêtable seulement par
SIGKILL, et rien ne le signalerait. Le lanceur vide donc le masque de
l’enfant (AirCommand::clear_signal_mask, additif couche 1 de cet incrément).
Ce piège a été trouvé par une exécution réelle, pas par un test :
air-sshdsousair-launchdne mourait qu’auSIGKILL. Il est depuis couvert par un test qui lit le masque de l’enfant dans/proc.
4.4 Un service ne se soustrait pas à sa supervision
Deux garanties, indépendantes, parce qu’aucune ne couvre à elle seule ce que couvre l’autre.
Un enregistrement non conforme est un motif d’ARRÊT DU SERVICE, jamais une raison
pour le lanceur de mourir. Un service compromis écrit huit octets de charabia sur
son canal ; le cœur les refuse — c’est son travail. Si ce refus remontait comme une
erreur d’I/O, le lanceur sortirait en laissant l’enfant orphelin, réparenté à
init, et seul détenteur des écoutes qu’il venait de lui remettre : bind
répondrait EADDRINUSE à tout redémarrage (le lanceur ne pose pas SO_REUSEADDR).
Huit octets pour échapper à sa supervision, et le port en prime. Le lanceur rapporte
donc le refus (Report::ProtocolViolation, portant le motif du cœur) puis enchaîne
sur la voie d’arrêt ordinaire (§4.1) et sort proprement, ayant arrêté ce qu’il
supervisait. Un refus survenu pendant le drainage du dernier mot relève de la
même famille et connaît le même sort.
Aucun service ne survit à la poignée qui le supervisait. LaunchedService porte
un Drop qui, si la fin du service n’a pas déjà été constatée et récoltée, le tue
et le récolte — sans jamais paniquer, et sans rien remonter (il n’y a plus personne
pour en faire quelque chose). C’est la seule construction qui rende l’invariant vrai
par construction plutôt que par ordonnancement : elle couvre les chemins d’erreur
qu’on n’a pas prévus. Son pendant, pour le cas où le lanceur ne vit plus du tout, est
PR_SET_PDEATHSIG armé à SIGKILL sur l’enfant : un lanceur tué net ne peut plus rien
demander, mais le noyau, lui, peut encore terminer son service.
Lequel opère où — et pourquoi la question se pose. Le profil de release du workspace
impose panic = "abort" : lors d’une panique, il n’y a aucun déroulement de pile,
donc aucun Drop. Sur le binaire déployé, une panique du lanceur est donc couverte
par PDEATHSIG, pas par le Drop ; celui-ci couvre les chemins d’erreur (sortie
par return, pile intacte) et les tests (compilés avec déroulement). Les deux
mécanismes sont indépendants, et c’est leur conjonction qui tient l’invariant dans les
deux régimes.
Corollaire, et il a mordu : le lanceur ne doit jamais paniquer sur son propre
journal. println! panique sur EPIPE (Rust ignore SIGPIPE) : un lecteur qui
disparaît — pipe vers logger, rotation, shell parent qui meurt — abattait le lanceur
par SIGABRT, et son service avec lui via PDEATHSIG, sans arrêt gracieux. Le
chemin de rapport écrit donc par des macros qui ignorent l’échec d’écriture : perdre
une ligne de journal est sans conséquence, perdre le service n’en est pas une.
4.5 Arrêter un service qui fork — le groupe, pas l’enfant direct
Le lanceur ne visait longtemps que son enfant direct : SIGTERM/SIGKILL partaient
par le pidfd, et PR_SET_PDEATHSIG ne s’hérite pas aux petits-enfants. Un service qui
fork — un air-sshd qui fork ses sessions, n’importe quel démon pré-forkant —
laissait donc des fils vivants derrière lui, porteurs de la copie héritée de
l’écoute : port occupé, redémarrage impossible, processus non supervisés en vie.
Fermé depuis le 2026-08-09. Le service est lancé chef de sa session
(AirCommand::new_session, 13ᵉ additif couche 1) : il est donc chef de son groupe de
processus, et ce groupe porte son PID. Tout ce qu’il fork y naît. L’arrêt vise le
groupe (air_signal::send_signal_to_process_group), et atteint ainsi le service et
sa descendance.
Pourquoi viser un groupe par le PID de l’enfant est sûr. Un groupe se désigne par
l’identifiant de son chef. Ce PID ne peut pas être recyclé tant que le lanceur n’a pas
récolté l’enfant — et il tient son pidfd jusque-là. Le groupe visé est donc le sien, ou
plus rien du tout (ESRCH) ; jamais celui d’un tiers, et jamais celui du lanceur. Sur
ESRCH, on retombe sur le pidfd, qui reste la voie exacte vers l’enfant.
Et le cas hostile, fermé par le cgroup (temps 3a). Un petit-fils qui appelle setsid
à son tour quitte le groupe et échapperait au signal — le groupe de processus est une
convention que la descendance peut rompre. Le service est donc aussi confiné dans un
cgroup (AirCgroupManager), dont on ne sort pas de sa propre initiative :
- il rejoint son cgroup avant son
execve(AirCommand::join_cgroup), donc avant d’avoir pu forker —cgroup.killne tue que ce qui est dedans, et un processus qui rejoindrait après aurait déjà semé ses fils ailleurs ; - à chaque arrêt, quelle que soit la voie empruntée, ce qui reste dans le cgroup est
tué. Le cgroup n’est pas le « dernier marteau » réservé aux services récalcitrants :
c’est la clôture. Un service qui cède au
SIGTERMpeut parfaitement laisser un descendant échappé derrière lui ; - rien n’est fatal : sur un système sans cgroup v2 délégué, le service est lancé quand
même et l’arrêt retombe sur le groupe de processus. Le journal le dit
(
CgroupUnavailable) — un opérateur doit savoir qu’il a perdu la frontière forte, sans quoi il croira l’avoir.
4.5.1 Borner ce qu’un service peut créer (temps 3b)
Le lanceur déménage au démarrage : il crée une racine, s’y déplace dans un sous-cgroup dédié, et les cgroups de service en sont les frères.
R/ ← racine ; VIDE, c'est elle qui distribue les contrôleurs
├── air-launchd.superviseur/ ← le lanceur, déplacé ici
└── air-launchd-<service>.<pid>.<n>/ ← le service, avec pids.max
Pourquoi ce déménagement est nécessaire, et pas une commodité. Un cgroup qui distribue
un contrôleur ne peut plus contenir de processus (no internal processes). Le noyau
accepte pourtant l’écriture dans le cgroup.subtree_control d’un cgroup peuplé — et
c’est ensuite que plus aucune migration vers ses enfants n’aboutit, pour personne. Un
lanceur lancé depuis un terminal rendrait ainsi le cgroup de la session entière
inutilisable. La couche 1 refuse donc avant d’écrire.
--max-processes <n> pose pids.max. Ce que cela fait : au-delà, fork échoue avec
EAGAIN chez le fautif ; aucun processus n’est tué, et le reste de la machine ne s’en
aperçoit pas. La borne compte les tâches, donc chaque fil.
--memory-high <Mio> pose memory.high : au-delà, le noyau freine le service et
récupère de la mémoire. Il ne le tue pas — c’est ce qui distingue ce seuil de
memory.max, qui appelle l’OOM killer et ne laisse au superviseur qu’un cadavre. Pour une
posture défensive, un service qui fuit doit devenir lent et visible.
Les valeurs par défaut, et ce qui les justifie
| réglage | défaut | mesure qui le fonde |
|---|---|---|
pids.max | 2048 | air-sshd coûte 4,00 tâches/connexion ⇒ les 256 pré-auth (1 + 4×256 = 1025) + ~256 sessions établies |
memory.high | 512 Mio | ~819 Kio/connexion ⇒ plus de 600 connexions ; les démons système mesurés plafonnent à 35 Mio |
Ces défauts s’appliquent sans être demandés : un service lancé par air-launchd n’est
jamais sans bornes (décision BDFL du 2026-08-09). Les retirer se demande explicitement —
--max-processes unlimited — et 0 est refusé : il interdirait au service d’exister, ce qui
est une faute de frappe et non une intention.
Ce que pids.max borne, et ce que rien d’autre ne borne : le plafond de concurrence
d’air-sshd (DEFAULT_MAX_PREAUTH_TOTAL = 256) ne couvre que la fenêtre pré-auth — le
crédit d’admission est rendu au succès de l’authentification. Les connexions établies
ne sont bornées par rien. C’est ce qui rend cette borne utile plutôt que redondante.
Reste à faire, sans blocage : exprimer ces valeurs en fraction de la capacité — ce
que fait systemd (DefaultTasksMax = 15 % de threads-max) — pour qu’elles suivent
d’elles-mêmes du Raspberry Pi 4 au serveur.
Corollaire opérationnel inchangé : le lanceur ne pose pas SO_REUSEADDR. Si quoi que
ce soit tient encore le port, l’échec au redémarrage est franc (EADDRINUSE) et n’est pas
masqué.
4.6 Relancer un service (temps 3c)
Le lanceur ne relançait rien : le service mort, il sortait. Il enchaîne désormais autant de vies que la politique en autorise.
--restart | comportement |
|---|---|
no | ne relance jamais |
on-failure (défaut) | relance un code non nul ou une mort par signal ; une sortie propre est un travail achevé |
always | relance aussi une sortie propre — ce que veut un démon dont toute sortie est anormale |
Ce qui n’est jamais relancé, quelle que soit la politique : un arrêt demandé (fin voulue) et une violation du contrat (relancer un service qui ment sur le fil, c’est relancer le mensonge).
Le recul double — 100 ms, 200, 400… plafonné à 30 s. Un service qui échoue à l’amorçage (binaire absent, configuration invalide) échoue instantanément, et le relancer aussitôt consommerait un cœur sans rien réparer. Le plafond existe parce que, sans lui, le doublement atteindrait des heures et le service cesserait de fait d’être supervisé. L’attente est interruptible : un ordre d’arrêt reçu pendant le recul est honoré tout de suite.
Le crédit est de 5 échecs consécutifs (--max-restarts), et un démarrage qui tient
10 s le rend entier — sans quoi un démon relancé cinq fois en cinq mois finirait par ne plus
l’être, ce qui n’est pas ce qu’un opérateur attend de « cinq tentatives ».
4.6.1 Les écoutes restent au lanceur
C’est le changement qui rend le redémarrage utile. Le lanceur relâchait ses écoutes après
le fork, au motif que « celui qui écoute est le service ». Vrai tant qu’un service ne vivait
qu’une fois : dès qu’il peut renaître, les relâcher rend le redémarrage impossible — le port
se libère à la mort du service, un tiers peut le prendre, et le lanceur ne pose pas
SO_REUSEADDR.
En les gardant, le port ne se libère jamais entre deux vies : les connexions qui arrivent pendant le redémarrage attendent dans la file du noyau au lieu d’être refusées. C’est ce que le §5 annonçait comme bénéfice de « les écoutes sont liées par le lanceur, jamais par le service ».
5. Le démon
air-launchd --exec <chemin-absolu> [--arg <argument>]… [--listen <ip:port>]…
[--expect-ready] [--stop-deadline-ms <n>]
Ligne de commande, pas manifeste : la description d’un service — ce qu’il demande, ce qu’on lui octroie — est l’objet d’ADR-150, un artefact binaire compilé depuis un texte par un outil dédié. Il n’existe pas encore, et le temps 1 ne l’attend pas ; la ligne de commande est le chemin d’administration, pas le chemin d’entrée d’une décision de sécurité. Le jour où le manifeste existe, il remplace ce parseur.
Les écoutes sont liées par le lanceur, jamais par le service : c’est ce qui permettra de redémarrer un service sans perdre une connexion en cours d’établissement, et c’est déjà ce qui évite au service le droit de lier un port privilégié.
Le lanceur rend le code de sortie du service, et rapporte en texte lisible ce que celui-ci lui a dit (Principe 11 : l’entrée est stricte, la sortie est lisible).
Pas encore packagé. air-launchd est inscrit à UNPACKAGED_LINUX_AIR
(xtask/src/check_target.rs) avec sa justification : au temps 1 il supervise un
service décrit sur une ligne de commande et ne met rien en cage. Le packager
aujourd’hui poserait sur les machines un superviseur incapable de la seule chose qui
justifie qu’Air en ait un à lui. Il rejoindra PACKAGES quand le manifeste et la cage
existeront. Le harnais de preuve (air-launch-temoin) y est inscrit lui aussi,
pour une raison différente et définitive : ce n’est pas un livrable.
6. Le critère de sortie, et sa preuve
air-sshddémarre sousair-launchdau lieu de systemd, sans qu’une ligne d’air-sshdchange.
Une seule ligne d’air-sshd a changé : SystemdHost::from_env() →
air_service::detect() — le point de sélection, qui ne changera plus.
Deux preuves, de natures différentes — et la seconde est la seule qui engage.
6.1 Preuve automatique (la logique), sur la pile hôte
crates/air-launch-temoin/tests/lancement.rs lance le service témoin de cette même
crate (qui ne nomme aucun superviseur), sur un vrai fork/exec : l’activation
traverse l’execve, les écoutes arrivent en nombre exact, l’ordre d’arrêt
redescend et est honoré, et un lanceur qui disparaît arrête le service.
Ce que cette preuve ne dit pas. Elle s’exécute sur la pile hôte gnu (
*-linux-gnu, glibc) — la cible du gate. Elle valide la logique du contrat, pas son fonctionnement sur la pile d’Air : lastdn’est pas la même, et c’est précisément la nôtre qui doit marcher. Un vert ici ne prouve rien detarget_os = "air".
6.2 Preuve on-target (la pile linux-air), et c’est celle qui compte
Binaires static-pie, sans libc C, std linux-air (ldd : statically
linked), exécutés sur les deux architectures tier-1 (ADR-014) :
| Arche | Machine | Noyau | Exécuté |
|---|---|---|---|
| x86_64 | air-test-host (carbon) | 7.0.0-27-generic | témoin et air-sshd |
| aarch64 | raspi-srv-2 — Pi 4 Model B Rev 1.4 (exécuteur ARM canonique) | 7.0.0-1015-raspi | témoin et air-sshd |
| aarch64 | raspi-srv-1 — Pi 4 Model B Rev 1.1 | 7.0.0-1015-raspi | témoin et air-sshd |
Les binaires aarch64 sont compilés en croisé depuis une machine x86_64 puis copiés :
static-pie sans libc C, rust-lld — aucun toolchain C croisé n’est nécessaire, et
rien n’est compilé sur le Pi.
Observé, identique des deux côtés :
air-launchd: <service> lancé (pid …), 1 écoute(s) remise(s)
air-launchd: état — ecoutes=1 ← le témoin a compté ce que le NOYAU lui a remis
air-launchd: service prêt ← READY reçu SUR LE CANAL
air-launchd: arrêt demandé, ordre transmis au service
air-launchd: <service> a terminé (code 0)
Ce que chaque ligne établit :
- le
READYne peut venir que du canal — aucune variableLISTEN_FDSniNOTIFY_SOCKETn’était posée, et lastdlinux-air n’offre pas d’autre chemin ; air-sshda servi la bannièreSSH-2.0-Air_0.7.0sur le socket hérité, sur les deux arches : l’écoute remise enSCM_RIGHTSest une écoute vivante ;- le témoin honore le
Stopdu canal et sort en0, sans qu’aucun signal ait eu à insister ;air-sshd, qui n’arme pas encore sa source d’arrêt, cède auSIGTERMd’insistance — ce qui vérifie du même coup, sur la cible, le correctif de masque de signaux d’ADR-151 ; - superviseur tué par
SIGKILL— donc sans la moindre chance d’émettre un ordre : le témoin le constate et s’arrête de lui-même (SupervisorGone). Vérifié sur ARM.
Réserve.
air-sshdtournait en mode legacy (--host-key-seed/--user/--key-blob-file), sans configuration binaire ni séparation de privilèges. Ce qui est prouvé on-target est l’activation et l’arrêt, pas une session SSH complète.
Leçon de méthode, à ne pas reperdre. La première version de cette section annonçait une preuve « réelle » qui était en fait un build hôte gnu. Sur un projet dont la raison d’être est une
stdsans libc C, une exécution sur glibc ne démontre rien de la cible — c’est le même piège que le journal consigne au 2026-07-11 (« le code compilait sur le Mac ; seule l’exécution a valu preuve »).
7. Ce que le temps 1 laisse ouvert
-
La cage (temps 2) — bloquée par la largeur des enveloppes par défaut, question ouverte n° 1 d’ADR-150.
-
Le
deadline_msn’est pas exposé au service. Il voyage dans leStop, maisShutdownReasonn’en dit rien à l’appelant : un service ne sait pas encore combien de temps il a pour draîner. -
air-sshdn’arme pas sa source d’arrêt : il ne lit pas leStop, et cède auSIGTERMd’insistance. Comportement identique à celui qu’il a sous systemd aujourd’hui — mais le canal permettrait un arrêt drainé, et ne pas le faire est une occasion manquée, pas un défaut du contrat. -
Un seul service par lanceur.LEVÉ le 2026-08-13 (ADR-157, temps 3a).air-launchdsans argument balaie la racine des services, arbitre chaque bundle installé, et supervise dans une seule boucle ceux qui portent un blocservice. L’invocation--execsubsiste comme chemin de diagnostic, marquéeSECOND-PATHà son point d’usage.Du temps 3b : les dépendances, l’ordre de démarrage et l’arrêt en ordre inverse sont décidés par [ADR-161] et faits (D3, D2, D5). D4 aussi — la mort d’une dépendance remet ses dépendants en attente, selon [ADR-171]. Le temps 3b est complet. Restent du temps 3 : les bornes (
pids.max,memory.max) et la comptabilité. Le §4.5 est fermé : groupe de processus pour le cas courant, cgroup pour le cas hostile.Corollaire opérationnel du temps 1 : le lanceur ne pose pas
SO_REUSEADDRsur ses écoutes. Tant que quoi que ce soit tient encore le port (un petit-fils qui a créé sa propre session, §4.5), aucun redémarrage n’est possible — l’échec est franc (EADDRINUSE) et n’est pas masqué. -
Le registre de noms et la capability initiale— livré le 2026-08-13 (ADR-010, ADR-157 étape 2b-ii). Le lanceur tient unAirRegistry(registry_host.rs) ;launchcrée la paire de sockets par service et en dépose un bout par le genre 6 avant le fork ; la boucle multi-service guette l’autre bout et sertRegister/Lookup;admitpose la politique au lancement etforgetla reprend à la mort constatée. Côté service,air-com::RegistryClientpublie et joint dans le vocabulaire du manifeste (publish/connect). Le mur du plafondaircomest tombé — plus aucune famille d’octroi n’est refusée faute d’applicateur.Ce que le registre ne fait pas : il ne relaie aucune charge applicative. Il met en relation, puis s’efface ; tout ce qui suit est p2p entre les deux bouts qu’il a mis face à face.
-
La détection consomme ce qu’elle lit. Si le descripteur 3 est un seqpacket Unix qui n’est pas notre canal, la sonde lui prend un message avant de refuser. C’est fail-closed et sans conséquence pour un service (il ne démarre pas), mais ce n’est pas gratuit pour le voisin. Observé pour de vrai : une première version d’un test a volé son message à un test frère dont la socketpair avait atterri sur le 3. Une variante
MSG_PEEKvaliderait l’en-tête sans consommer — au prix des descripteursSCM_RIGHTSque le noyau installerait pendant le peek et qu’il faudrait refermer. À trancher si le cas se présente ailleurs que dans un binaire de test multi-fils.
Licence du document : MPL 2.0