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

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 :

  1. le descripteur 3 est-il un socket AF_UNIX/SEQPACKET (SO_DOMAIN/SO_TYPE) ? sinon ⇒ pas activé, et rien n’est touché ;
  2. 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é ;
  3. est-ce un Hello conforme ? 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

TempsMoyenCe que ça suppose du service
demanderenregistrement Stop sur le canalqu’il écoute son canal
insisterSIGTERMqu’il soit un programme Unix ordinaire
terminerSIGKILLrien

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-sshd sous air-launchd ne mourait qu’au SIGKILL. 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.kill ne 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 SIGTERM peut 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églagedéfautmesure qui le fonde
pids.max2048air-sshd coûte 4,00 tâches/connexion ⇒ les 256 pré-auth (1 + 4×256 = 1025) + ~256 sessions établies
memory.high512 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.

--restartcomportement
none relance jamais
on-failure (défaut)relance un code non nul ou une mort par signal ; une sortie propre est un travail achevé
alwaysrelance 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-sshd démarre sous air-launchd au lieu de systemd, sans qu’une ligne d’air-sshd change.

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 : la std n’est pas la même, et c’est précisément la nôtre qui doit marcher. Un vert ici ne prouve rien de target_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) :

ArcheMachineNoyauExécuté
x86_64air-test-host (carbon)7.0.0-27-generictémoin et air-sshd
aarch64raspi-srv-2 — Pi 4 Model B Rev 1.4 (exécuteur ARM canonique)7.0.0-1015-raspitémoin et air-sshd
aarch64raspi-srv-1 — Pi 4 Model B Rev 1.17.0.0-1015-raspité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 READY ne peut venir que du canal — aucune variable LISTEN_FDS ni NOTIFY_SOCKET n’était posée, et la std linux-air n’offre pas d’autre chemin ;
  • air-sshd a servi la bannière SSH-2.0-Air_0.7.0 sur le socket hérité, sur les deux arches : l’écoute remise en SCM_RIGHTS est une écoute vivante ;
  • le témoin honore le Stop du canal et sort en 0, sans qu’aucun signal ait eu à insister ; air-sshd, qui n’arme pas encore sa source d’arrêt, cède au SIGTERM d’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-sshd tournait 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 std sans 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

  1. La cage (temps 2) — bloquée par la largeur des enveloppes par défaut, question ouverte n° 1 d’ADR-150.

  2. Le deadline_ms n’est pas exposé au service. Il voyage dans le Stop, mais ShutdownReason n’en dit rien à l’appelant : un service ne sait pas encore combien de temps il a pour draîner.

  3. air-sshd n’arme pas sa source d’arrêt : il ne lit pas le Stop, et cède au SIGTERM d’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.

  4. Un seul service par lanceur. LEVÉ le 2026-08-13 (ADR-157, temps 3a).

    air-launchd sans argument balaie la racine des services, arbitre chaque bundle installé, et supervise dans une seule boucle ceux qui portent un bloc service. L’invocation --exec subsiste comme chemin de diagnostic, marquée SECOND-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_REUSEADDR sur 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é.

  5. Le registre de noms et la capability initialelivré le 2026-08-13 (ADR-010, ADR-157 étape 2b-ii). Le lanceur tient un AirRegistry (registry_host.rs) ; launch cré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 sert Register/Lookup ; admit pose la politique au lancement et forget la reprend à la mort constatée. Côté service, air-com::RegistryClient publie et joint dans le vocabulaire du manifeste (publish/connect). Le mur du plafond aircom est 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.

  6. 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_PEEK validerait l’en-tête sans consommer — au prix des descripteurs SCM_RIGHTS que 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