ADR-069 — Registre de handles fd-général (couche 1, air-handle) + moteur socket, façades PAL & libc
Statut : Accepté (2026-07-05, ratification BDFL).
Contexte de déclenchement : jalon M4 réseau de la libc C-ABI scopée OpenSSH. La question « comment la libc socket se pose-t-elle sur la couche 1 ? » a révélé un enjeu plus large que le réseau : la cohérence de l’état des descripteurs entre le code Rust et le code C au sein d’un même processus.
Contexte
La vision (rappel)
Le but d’Air n’est pas une énième libc (sinon musl suffirait) : c’est offrir au développeur C, sous la forme d’une libc standard, un moteur Rust robuste, sûr et observable — garanties qu’une libc « sans état » ne peut pas donner (détection de fuites de fd, couture de sécurité réseau, évolutivité des mécanismes internes sans re-concevoir la libc). L’état userland est donc le produit, pas un accident.
L’invariant dur : Rust et C cohabitent dans le même exécutable
On peut mixer Rust et C dans un même binaire. Il ne doit donc jamais exister deux
chemins — un « chemin Rust » (objets air-socket appelant la couche 0 en direct) et
un « chemin C » (passant par un moteur). Les deux mondes doivent avoir une vue
unique et synchronisée de l’état des descripteurs. Sinon : la couture de sécurité
devient contournable (il suffit d’écrire en Rust), et l’état diverge du kernel.
Ce qui existe aujourd’hui (analyse)
- Couche 0 (
air-sys-syscall::{net,fs}) : familles socket et duplication complètes, fd-centrées, typées. Duplication = deux primitives :fs::dup_fd(F_DUPFD_CLOEXEC, alloue le plus petit numéro libre) etprocess::dup3(cible un numéro exact, atomique). - Couche 1 : les objets (
AirFile,AirTcpSocket,AirUdpSocket,AirUnix*) appellent la couche 0 en direct et ne maintiennent AUCUN état userland (AirFile::duplicate=dup_fd+ wrap). Ils sont donc cohérents trivialement (rien de partagé ne peut diverger), mais sans moteur, sans registre, sans couture. - Libc (M2) :
dup/dup2/dup3+fcntl(F_DUPFD/F_DUPFD_CLOEXEC/F_GET/SET_FD/FL)— facades sans état sur la couche 0 (motif « shim C variadique + logique Rust mesurée » pourfcntl).
Pourquoi dup2/fcntl sont le point de rupture
Dès qu’on introduit un état userland (le registre), dup2(old, new) le stresse :
- Alias —
newetoldpointent la même description ouverte (mêmes status flags, même offset). Deux numéros, un objet. Un registre « fd → état indépendant » se trompe : il faut modéliser description partagée (status flags/offset) vs numéro de fd (FD_CLOEXECpar-fd). - Fermeture silencieuse de la cible — si
newétait enregistré,dup2le détruit atomiquement ; le registre doit évincer cette victime avant d’insérer l’alias, sinon fuite d’entrée et double-close.
fcntl rejoue le problème : fcntl(F_DUPFD) = duplication (même souci d’alias) ;
fcntl(F_SETFL, O_NONBLOCK) mute les status flags de la description → partagés
par tous les fd aliasés (donc à modéliser au niveau description, jamais caché par-fd).
Décision
1. Une nouvelle crate couche 1 : air-handle (registre de handles fd-général)
Un registre process-global unique (un static), clé = vrai fd kernel, plus bas
dans le graphe que tous les producteurs de fd (air-filesystem, air-socket,
air-process, air-poll en dépendent). Deps : couche 0 (air-sys-syscall,
air-sys-types) + air-base-core (AirError). no_std + alloc.
Modèle d’une entrée :
fd (RawFd) → HandleEntry {
description: Rc<OpenDescription>, // partagée entre alias (dup) — refcount userland miroir du kernel
kind: HandleKind, // File | Socket | Pipe | Adopted(external)
cloexec: bool, // PAR-FD (F_SETFD)
metadata / hooks… // provenance, politique
}
OpenDescription { owned_fd: OwnedFd (une seule fois par description), status flags, … }
- Le registre POSSÈDE l’
OwnedFd(D1). Il est l’unique propriétaire RAII : les façades (objets Rust, code C) ne tiennent que le numéro de fd + la responsabilité d’appelerclose. Fuite = entrée résiduelle → détectable (LIB_AIR_DEBUG). - Vrai fd kernel rendu au C (invariant d’interop :
poll/select/fcntl/SCM_RIGHTSl’exigent). Le registre enveloppe le fd kernel, il ne le remplace pas par un handle opaque.
2. Points de passage UNIQUES
Toute opération de cycle de vie passe par le registre : create (open/socket/pipe/
accept), duplicate, close, set_fd_flags (CLOEXEC, par-fd),
set_status_flags (par-description). Aucun producteur de fd de couche 1 n’appelle
plus la couche 0 en direct pour ces opérations.
3. Le duplicateur unique (D6)
duplicate(old_fd, target: Option<RawFd>, cloexec: bool) -> AirResult<RawFd>
target = None→dup_fd(plus petit numéro libre) ;target = Some(n)→dup3.- Si
targetdésignait un fd enregistré, évincer la victime d’abord (le kernel l’a fermée atomiquement). - Enregistrer le nouveau fd comme alias (même
Rc<OpenDescription>queold). - Les 5 portes C (
dup/dup2/dup3/fcntl(F_DUPFD)/fcntl(F_DUPFD_CLOEXEC)) et le Rust (AirFile::duplicate, etc.) convergent tous vers cette seule primitive.
4. Identité de description : best-effort (D2)
L’alias n’est connu que quand c’est nous qui dupliquons. Un fd adopté
(hérité du parent, 0/1/2, provenance externe) = description singleton
(adoption-à-l’usage). Limite honnête ; suffisante (aucune couche userspace ne peut
reconstruire les alias qu’elle n’a pas créés).
5. Re-domiciliation TOTALE dès le lot fondateur (D5)
Tous les objets couche 1 producteurs de fd — AirFile, pipes, AirTcpSocket,
AirTcpListener, AirUdpSocket, AirUnixStream/Listener/Datagram — sont
re-domiciliés sur le registre : ils délèguent création/duplication/fermeture au
registre. API publique préservée (refactor interne à comportement constant).
L’adoption-à-l’usage ne sert que pour les fd vraiment externes.
6. Le moteur socket = spécialisation politique par-dessus le registre
AirSocketEngine n’est pas un acteur parallèle : c’est une couche de politique
au-dessus du registre pour les entrées kind = Socket (hooks connect/bind/send,
câblage du résolveur AirNameResolver — réutilisé tel quel pour getaddrinfo).
Consommé par deux façades : les objets Rust (AirTcpSocket… pour le PAL/devs Rust)
et la libc C. Une impl, deux faces (doctrine ADR-068/dual-face).
7. Concurrence & no_std (D3/D4)
- Registre partagé entre threads et langages → un
Mutex(verrou couche bas-niveau : futex couche 0 ou verrou interne, sans cycle versair-thread). Shardage après mesure (Principe 5). (Différent de l’errno, qui est thread-local : la vue des handles doit être commune.) no_std+alloc: map allouée + verrou. Assainir au passage la featurestdd’air-sys-typesdansair-socket(aujourd’hui activée inconditionnellement → tirestddans le graphe) pour garder le chemin de productionno_std-propre on-target.
8. La libc reste une façade mince
Les shims C-ABI (socket/bind/connect/…/dup2/fcntl/close) routent vers le
registre/moteur ; la logique irréductiblement C (parsing du blob sockaddr,
inet_pton, byte-order) vit dans une crate logique mesurée + fuzzée
(air-libc-socket). Les dup/dup2/dup3/fcntl/close déjà livrés (M2) sont
re-pointés vers le registre.
Gouvernance
- Descellement ADDITIF de la couche 1 (délégation ADR-065 : additifs révélés par les
toits libc) → re-sceau
couche-1-v1.x. Ajout de la crateair-handle; refactor à comportement préservé (API publique inchangée) des crates fd de couche 1 scellées. - ADR ratifié BDFL AVANT tout code (ADR-015 : conception structurante par RFC, jamais par raccourci dans le code).
- Périmètre borné OpenSSH ; élargissements (verrous
fcntl, options socket rares, drapeauxAI_*exotiques) = additifs ultérieurs documentés.
Conséquences
Positives :
- Vue unique Rust/C garantie par construction (un registre, clé = fd kernel, un seul
chemin ;
dup2/fcntlcohérents via description partagée + éviction). - Couture de sécurité réseau : tout trafic socket passe par les hooks du moteur → politiques (destinations, filtrage, quotas) ajoutables sans toucher la libc.
- Robustesse/observabilité : propriété RAII centralisée → détection de fuites et de
double-close (
LIB_AIR_DEBUG), inventaire des handles vivants. - Évolutivité : le registre/moteur est l’interface stable ; ses internes évoluent librement derrière les façades.
Coûts / risques :
- Refactor large de crates couche 1 scellées (toutes les faces fd re-domiciliées).
- Verrou global du registre sur le chemin des ops fd (mitigé : ops non ultra-chaudes ; shardage après mesure).
- Limite irréductible : du code faisant des syscalls bruts (contournant objets ET libc) reste invisible jusqu’à l’adoption-à-l’usage — l’isolation dure reste seccomp/Landlock, pas le registre.
Alternatives rejetées
- Libc → couche 0 en direct : le plus court, mais aucune couture de sécurité, casse « libc via couche 1 », deux chemins.
- Registre socket-only :
dup2/fcntlopèrent sur tous les fd → deux régimes (sockets suivis, fichiers non), cas mixtes ambigus. Rejeté au profit du fd-général. - Handle synthétique (int opaque = clé de table) : casse l’interop C
(
poll/select/SCM_RIGHTS) → trahit « libc C standard ». - Libc sans état : c’est musl ; nie la valeur d’Air.
Suite (séquencement des lots)
air-handle: registre + duplicateur + close + adoption + hygiènestd; re-domiciliation de tous les objets fd couche 1 ; re-pointagedup/dup2/dup3/ fcntl/closelibc. → re-sceau couche 1.AirSocketEngine: politique + ops fines socket, sur le registre.air-libc-socket:sockaddr/inet_pton/byte-order (mesuré + fuzzé).- Shims socket bruts → moteur.
getaddrinfo/getnameinfo→ résolveur.poll/select.