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-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 couche FILE* 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 reste no_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 Tid du 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 pilotent clone3) ré-initialise le verrou du registre immédiatement côté enfant — même discipline que le verrou de air-alloc et que air-stdio::reset_after_fork.
  • Fenêtre fork→exec async-signal-safe. Entre clone3 et execve, aucune allocation, aucun verrou bloquant (ADR-021 : enfant clone3 = jamais de malloc). 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. execve remplace l’image : le registre (en mémoire) disparaît avec elle ; les fd CLOEXEC sont 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 un fork() (piège classique : le tampon dupliqué provoque une double sortie). Le suivi du tampon vit dans la couche FILE* (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 (flush FILE*), 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 par open/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 flush FILE* avec la stdio C. Tests air-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.