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-135 — Descellement additif couche-1-v3.5 : le duplicateur de descripteurs fermait, lui aussi, le descripteur neuf

Statut : Accepté (2026-07-29). RFC de structure (ADR-015). Applique le modèle de re-sceau du sceau couche 1 (ADR-062) sous la délégation additive du superviseur (ADR-065). Jumeau d’ADR-131 §3, dont il achève la correction.

Catégorie : Descellement ADDITIF de la couche 1 scellée (couche-1-v3.4couche-1-v3.5). Aucun ajout de symbole, aucun retrait, aucun changement de signature : la surface publique de air-handle est rigoureusement inchangée. C’est une correction de défaut au comportement d’une primitive scellée — une fermeture qui avait lieu n’a plus lieu — et elle est à ce titre remontée explicitement au superviseur.

Le tag n’est pas posé par cet ADR. Conformément au modèle de re-sceau, la pose du tag couche-1-v3.5 revient au superviseur, après revue.

Contexte

ADR-131 §3 avait corrigé, dans air_handle::register, un défaut du registre de descripteurs : une entrée périmée — laissée par la purge close_range post-fork des étages privsep (ADR-125 §D8), qui ferme les descripteurs du noyau sans passer par le registre — était Dropée lors de l’enregistrement suivant portant le même numéro. Son Drop ferme le numéro, c’est-à-dire le descripteur neuf. Symptôme : EBADF sur un descripteur pourtant valide.

Le correctif d’ADR-131 était juste, mais incomplet. Le registre a deux portes d’entrée, pas une : register, qui enregistre un descripteur fraîchement créé, et duplicate, le duplicateur unique qui enregistre le descripteur rendu par dup_fd/dup3. La seconde portait le même défaut, à la ligne finale — l’ancienne valeur rendue par table.insert y était liée à _previous, donc droppée :

#![allow(unused)]
fn main() {
let fd = new_owned.as_raw_fd();
let _previous = table.insert(fd, HandleEntry { owned: new_owned, kind });  // ← ferme `fd`
Ok(AirHandle { fd })
}

Le défaut est resté invisible à toute la campagne V2.5/V2.6 pour une raison précise : les tests de session privsep tournaient sur une boucle in-process (daemon_privsep_root), où la purge close_range n’est pas jouée sur le registre du processus de test. C’est la preuve de bout en bout d’ADR-134 — la première à monter la chaîne listener → monitor → enfant → worker depuis le binaire livré — qui l’a révélé.

Le symptôme, côté utilisateur, est le pire des trois possibles : la session ssh aboutit (le client sort à 0, le KEX est signé, la clé autorisée), et rien ne s’exécute. Le worker duplique ses descripteurs de stdio avant son execve ; l’un d’eux reçoit un numéro qu’une entrée périmée du registre revendiquait encore ; le Drop de cette entrée le referme entre le dup et l’execve. Le processus meurt avant de commencer.

Décision

Descellement additif couche-1-v3.5, sur une crate — air-handle — et une fonction : duplicate oublie (au lieu de la fermer) l’entrée déjà présente pour le numéro que le noyau vient de lui attribuer.

#![allow(unused)]
fn main() {
if let Some(stale) = table.insert(fd, HandleEntry { owned: new_owned, kind }) {
    core::mem::forget(stale);
}
}

Le raisonnement est mot pour mot celui d’ADR-131 §3, et il ne dépend pas de la porte d’entrée : le noyau ne réattribue pas un numéro de descripteur vivant. Une collision dans la table prouve donc que l’ancienne entrée ne décrit plus rien, et que son OwnedFd désigne désormais le descripteur neuf. La fermer, c’est arracher à ce dernier son propre numéro. On l’oublie.

Et si, par impossible, l’ancienne entrée était vivante ? L’oublier ne ferait que fuir un descripteur, là où la fermer arracherait à un autre propriétaire un descripteur qu’il croit sien. Entre les deux, la fuite est de loin la moins nocive — même arbitrage qu’ADR-131.

Les deux voies de duplicate sont couvertes. La voie target = Some(new) (dup3) évinçait déjà sa victime en amont, par un remove + forget explicite (l’éviction-sur-silent-close d’ADR-069) : l’insertion finale y trouve donc toujours la place libre. Seule la voie target = None (dup_fd, qui alloue le numéro et ne peut donc pas le savoir d’avance) atteignait le défaut. Le correctif est posé sur l’insertion commune, ce qui ferme la question pour les deux — et pour toute voie future.

Aucun symbole ajouté, aucune signature modifiée. Le test de régression duplicating_onto_a_stale_number_does_not_close_the_fresh_fd échoue sans le correctif — vérifié, EBADF — et passe avec.

Conséquences

  • Une session privsep lancée depuis le binaire livré exécute enfin la commande demandée : le dernier blocage fonctionnel du déploiement 0.3.0 tombe.
  • Le motif « purger ses descripteurs après un fork, puis en ouvrir/dupliquer de nouveaux » devient sûr par les deux portes du registre. Tout service qui suivra le motif privsep en bénéficie sans le savoir.
  • Le registre n’a plus, à notre connaissance, de chemin par lequel une entrée périmée puisse fermer un descripteur vivant : register (ADR-131), duplicate voie dup3 (ADR-069) et duplicate voie dup_fd (le présent ADR) sont les trois insertions du module ; adopt est protégée par un contains_key + retour anticipé.

Ce que cet ADR ne fait pas

  • Aucun ajout de surface. Pas un symbole, pas un paramètre, pas une variante.
  • Aucun changement au registre lui-même (structure, verrou, propriété RAII).
  • Aucune reprise de la purge close_range d’air-process. Elle reste ce qu’elle est — un balayage post-fork qui n’alloue ni ne verrouille — et c’est bien pour cela qu’elle ne peut pas tenir le registre à jour.

Alternatives rejetées

  • Faire purger le registre par close_inherited_fds_except (air-process). Rejeté pour la même raison qu’en ADR-131 : la purge s’exécute dans la fenêtre post-fork, où prendre un verrou et allouer est précisément ce qu’on s’interdit. Le bon endroit est celui qui sait ce qu’une collision signifie : le registre.
  • Paniquer sur collision (« invariant violé »). Rejeté : dans un processus privilégié ou confiné, une panique est un déni de service offert à l’appelant, là où le comportement correct est parfaitement défini.
  • Factoriser une fonction interne insert_forgetting_stale partagée par register et duplicate. Rejeté pour l’instant : les deux sites tiennent déjà le verrou dans des portées différentes (duplicate le garde sur tout le dup*), et l’abstraction masquerait le raisonnement — qui est ce qui compte ici (Principe 7). Le prix est une duplication de trois lignes, documentée des deux côtés.

Points remontés au BDFL

  1. Correction de comportement d’une primitive scellée (air_handle::duplicate). Aucune signature ne change, mais une fermeture qui avait lieu n’a plus lieu. Le défaut est latent depuis l’introduction du duplicateur (ADR-069) et n’avait aucun symptôme tant qu’aucun étage purgé ne dupliquait de descripteur.
  2. Un défaut jumeau a survécu quatre jours à son propre correctif. ADR-131 §3 avait correctement diagnostiqué la cause et correctement corrigé un des deux sites. La leçon n’est pas sur ce défaut-ci mais sur la méthode : quand une correction porte sur un invariant (« une entrée présente est périmée »), l’audit doit énumérer tous les sites qui le supposent, pas seulement celui qui a mordu. Les trois insertions du module sont désormais énumérées ci-dessus.
  3. La couverture in-process ne prouve pas le binaire. Les deux défauts ont été trouvés par la même méthode — faire tourner le vrai binaire — et manqués par la même méthode — la boucle in-process. C’est un argument pour maintenir au moins un test root de bout en bout sur le binaire livré à chaque topologie de privsep.