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.4 → couche-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.5revient 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),duplicatevoiedup3(ADR-069) etduplicatevoiedup_fd(le présent ADR) sont les trois insertions du module ;adoptest protégée par uncontains_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_ranged’air-process. Elle reste ce qu’elle est — un balayage post-forkqui 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_stalepartagée parregisteretduplicate. Rejeté pour l’instant : les deux sites tiennent déjà le verrou dans des portées différentes (duplicatele garde sur tout ledup*), 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
- 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. - 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.
- 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.