ADR-063 — Instrumentation IO couche 1 (mode debug) : registre de handles cross-langage & cohérence fork
Statut : Accepté (2026-07-03, décision BDFL directe). RFC de structure
(ADR-015). Prérequis de conception du sceau
couche-1-v1.0 (ADR-062, réservé, à venir) : cet ADR fige
la doctrine de l’instrumentation ; son seam étant interne, l’implémentation
peut atterrir de façon additive avant ou après le sceau. Companion de
ADR-019 (Result lossless), ADR-021
(EINTR remonté, enfant clone3 = jamais de malloc), et de la vision « libc dual-face
traçante » (docs/notes/).
Catégorie : Doctrine d’architecture couche 1. Aucune surface livrée par défaut (feature compilée hors release), aucun impact ABI, zéro coût dans le build standard.
Contexte
Air possède les deux faces d’accès aux ressources IO : le code Rust consomme
l’API couche 1 (AirFile, air-socket, air-stdio) ; le code C d’un développeur
consomme la libc Air (C-ABI : FILE*, open/socket), elle-même bâtie sur
les mêmes briques couche 1. Ces deux faces partagent une vérité de bas niveau :
la table des descripteurs du noyau, par processus.
Cette position — que ni la glibc ni musl n’exploitent proprement — permet une
capacité distinctive : en mode debug, offrir au développeur une vue unifiée,
process-globale, cross-langage de tout ce qui est ouvert (fichiers, sockets,
FILE*), par qui (le thread ouvreur), et ce qui n’est ni fermé ni flushé —
particulièrement quand il passe par du C, où le compilateur ne l’aide pas.
En release, la couche 1 doit rester fidèle à ses principes : pas d’allocation
dans le happy path, no_std-compatible, zéro globale. L’instrumentation
ne peut donc pas être un état global toujours-présent : elle est opt-in.
Décision
1. Registre de handles IO, opt-in, mode debug
La couche 1 héberge un registre de handles — une table fd → enregistrement,
process-globale, protégée par un verrou fork-safe — alimentée par toutes les
briques qui ouvrent un descripteur :
AirFile(fichiers),air-socket(sockets), les pidfd/signalfd/timerfd, et la coucheFILE*de la libc C (au-dessus de la couche 1) qui s’y enregistre aussi.
Chaque enregistrement porte : le type (fichier / socket / FILE* / autre), la
provenance = le thread ouvreur (Tid via gettid, couche 0) + un site
d’ouverture (au minimum le nom logique de l’appel ; enrichissable), les flags
d’ouverture, l’origine langage (Rust API vs face C), les verrous détenus
sur le handle (§5), et — pour les FILE* — l’état de tampon (propre / sale,
pour le diagnostic de flush).
Le registre n’est ni une source de vérité ni un cache de correctness : la
vérité reste la table du noyau. C’est un miroir de diagnostic. fstat//proc/self/fd
restent l’oracle ; le registre y ajoute la provenance que le noyau ne connaît pas.
2. Gating — zéro coût par défaut
Deux verrous cumulatifs :
- Compile-time : feature Cargo
io-instrumentation(nommage définitif à l’impl). Désactivée en release ⇒ les points d’instrumentation se compilent en no-op (aucun code, aucune globale, aucun verrou) : la couche 1 resteno_std/zéro-alloc/zéro-globale. - Runtime : quand la feature est compilée, un interrupteur d’environnement
(
AIR_IO_DEBUG, cohérent avec la face C) active effectivement l’enregistrement. Un build debug non instrumenté à l’exécution reste donc silencieux et quasi gratuit.
3. Cohérence clone3/fork() parent↔enfant — exigence dure
Le registre doit rester cohérent à travers un fork() (clone3 sans
CLONE_VM) :
- État copié = état correct. L’enfant hérite l’espace d’adressage et les fd
ouverts du parent : le registre, copié tel quel, décrit déjà la réalité de
l’enfant. On n’invente rien ; on annote les entrées comme « héritées de
PID p » (la provenance
Tiddu parent est conservée comme historique). - Verrou ré-initialisé côté enfant. Un
fork()concurrent d’un thread tenant le verrou du registre laisserait l’enfant avec un verrou verrouillé à jamais (problème classique threads+fork). Le chemin de fork couche 1 (air-runtime/air-process, qui pilotentclone3) ré-initialise le verrou du registre immédiatement côté enfant — même discipline que le verrou deair-allocet queair-stdio::reset_after_fork. - Fenêtre fork→exec async-signal-safe. Entre
clone3etexecve, aucune allocation, aucun verrou bloquant (ADR-021 : enfantclone3= jamais demalloc). Toute mise à jour du registre dans cette fenêtre est soit différée, soit async-signal-safe (ré-init de verrou + marquage, sans alloc). exec= remise à zéro naturelle.execveremplace l’image : le registre (en mémoire) disparaît avec elle ; les fdCLOEXECsont fermés par le noyau. Aucune action requise — seul le cas fork sans exec (l’enfant continue du code Air/Rust) exerce la copie+ré-init ci-dessus.
4. Diagnostics développeur
Sur requête explicite (API de diagnostic), à la fermeture incohérente, et à la
terminaison du processus (dump type atexit) — et optionnellement avant un
fork :
- Fuites : descripteurs encore ouverts en fin de vie (hors exception §6).
- Flush :
FILE*à tampon sale non flushé (perte de données) — signalé à l’exit, et avant unfork()(piège classique : le tampon dupliqué provoque une double sortie). Le suivi du tampon vit dans la coucheFILE*(face C) ; il s’enregistre auprès du même registre couche 1. - Verrous non libérés : un verrou fichier acquis et jamais explicitement relâché, détenu jusqu’à la fermeture du handle / la fin du thread / la fin du processus (§5).
- Chaque diagnostic nomme la provenance (thread + site + langage), pour pointer précisément le C fautif.
5. Rust comme C — encourager la libération explicite (close, unlock)
Fermeture. En Rust, le Drop d’AirFile/socket ferme le fd par défaut
(RAII, best-effort, erreur avalée). C’est sûr mais opaque : une erreur de
close (EIO NFS…) est perdue. L’instrumentation distingue la fermeture
explicite (close(self), qui restitue l’erreur — cf. AirFile::close) de la
fermeture implicite par Drop, et signale les secondes comme « fermeture
implicite — préférez un close() explicite pour observer l’erreur ». Diagnostic de
faible sévérité (nudge de style, jamais une erreur) : le Drop reste un filet de
sécurité correct.
Verrous fichier. Un développeur ouvre un fichier, seek, pose un verrou,
garde le fichier ouvert… et ne l’unlock jamais, laissant le noyau le relâcher à
la fermeture du fd, à la fin du thread ou du processus. Le noyau nettoie certes
(les verrous ne fuient pas au sens ressource), mais cette libération implicite
est un code-smell : elle masque l’intention, prolonge la contention pour les
autres processus/threads en attente du verrou, et peut engendrer des blocages
subtils. L’instrumentation suit chaque verrou détenu — variante
(OFD fcntl — variante moderne préférée, ADR-021 / flock / verrou POSIX
record legacy / lease), type (partagé/exclusif), plage (offset+longueur
pour les record locks), Tid acquéreur et site — et signale, en
Rust comme en C, tout verrou relâché implicitement (par close/fin de
thread/fin de process) plutôt que par un unlock explicite. Même philosophie
que le nudge close, faible sévérité.
Nuance fork (cohérence §3). Les verrous OFD et flock sont attachés à la
description de fichier ouverte : ils sont hérités avec le fd à travers
fork() — le registre les annote « hérités » côté enfant. Les verrous POSIX
record (legacy) ne sont pas hérités par l’enfant : le registre ne doit
jamais rapporter un verrou POSIX record fantôme dans l’enfant. Cette asymétrie
est une raison de plus de préférer les verrous OFD (doctrine « variantes modernes »).
6. Exception stdin/stdout/stderr (fd 0/1/2)
Les trois descripteurs standard sont de sémantique OS : durée de vie =
processus, fermés par le noyau à l’exit. Ils ne sont jamais rapportés comme
fuites. (Leur flush FILE* en sortie normale relève du chemin atexit standard de
la stdio, pas d’un diagnostic de fuite.)
7. Seam interne ⇒ additif, non bloquant pour le sceau
Les points d’instrumentation (registre::on_open/on_close/on_flush) sont internes
aux crates ouvreuses : ils ne changent aucune signature publique. Les ajouter à
AirFile/air-socket/air-stdio (déjà sur la voie du sceau) est donc purement
additif et non-ABI-breaking, y compris après le sceau. Cet ADR fige la
doctrine maintenant pour la cohérence ; l’implémentation est séquencée à part.
Conséquences
- Nouvelle crate couche 1 (registre) sous feature, dont dépendent (dep interne, optionnelle) les crates ouvreuses. Zéro dépendance externe.
- La couche
FILE*de la libc C (au-dessus de la couche 1) devra s’enregistrer et suivre l’état de tampon — donc ce chantier a une part couche 1 (registre + hooks Rust) et une part face C (flushFILE*), cette dernière atterrissant avec la stdio C. - Le chemin de fork couche 1 gagne une responsabilité de ré-init (sous feature) —
testée façon
air-fault(§3.2 roadmap) + un test on-target fork/thread réel. - Valeur produit : une libc « qui fait ce qu’elle dit » et traçable, avantage net sur glibc/musl pour le développeur C.
Alternatives rejetées
- Registre toujours-actif (même en release). Rejeté : viole zéro-alloc/
no_std/ zéro-globale du happy path couche 1, et coûte un verrou paropen/close. - Suivi par thread (TLS) sans vue globale. Rejeté : les fd sont process-globaux (partagés entre threads, hérités au fork) ; le développeur veut une vue process. Le TLS ne sert qu’à du scratch par-thread éventuel, pas au registre.
- Dupliquer la table du noyau comme source de vérité. Rejeté : la vérité reste le noyau ; le registre n’ajoute que la provenance. Pas de risque d’incohérence autorité-vs-miroir.
- Warnings de fuite sur 0/1/2. Rejeté : sémantique OS (§6).
Suite
- Implémentation : crate registre (feature) + hooks
AirFile/air-socket/air-stdio(open/close et pose/levée de verrou) + ré-init fork couche 1 ; puis suivi flushFILE*avec la stdio C. Testsair-fault+ fork/thread on-target (dont l’asymétrie d’héritage OFD vs POSIX record). - À référencer depuis la roadmap couche 1 (chantier additif) et l’INDEX.