ADR-158 — Versionnement des symboles des bibliothèques Air : instruction et décision
Statut : Accepté (2026-08-14, BDFL) — voie A, mise en œuvre DIFFÉRÉE sous condition
nommée. Instruit l’outil air-symver annoncé par ADR-012 et
par macro-architecture-fr.md, et jamais écrit.
Catégorie : Stabilité contractuelle. Décide comment Air tient sa promesse d’ABI sur dix ans au niveau des symboles, et non plus seulement des déclarations.
Contexte
ADR-012 promet une ABI garantie dix ans et nomme trois outils
mainteneurs — air-abi-check, air-symver, air-deprecation-tracker — « à intégrer dès la
phase 0 ». Aucun des trois n’a jamais été écrit ; le constat a été fait le 2026-08-14, à
l’occasion de la règle NO GO inscrite en tête de CLAUDE.md.
Le premier a reçu un début de réponse le même jour : cargo xtask check-abi fige la surface
des déclarations des quatre headers C committés (392 au total) et refuse qu’une
déclaration disparaisse ou change de signature. C’est réel, et c’est borné : le gate lit des
headers, pas des bibliothèques.
Ce que la mesure montre, et que les déclarations ne disaient pas
Confrontation des headers aux symboles réellement exportés par les cdylib
(nm -D --defined-only, build de développement) :
| Bibliothèque | Déclarés | Exportés | Écart |
|---|---|---|---|
libair_base_c.so | 66 | 66 | aucun |
libair_value_c.so | 20 | 20 | aucun |
libair_object_c.so | 27 | 47 | 20 exportés sans déclaration |
libair_c.so | 279 | 296 | 11 _Unwind_*, 7 symboles Air non déclarés, + air_main |
Trois faits en découlent, de natures différentes :
libair_object_c.soexpose vingt symboles que son header ne déclare pas — l’APIair_value_*, ré-exportée du fait de la liaison statique de larlibsous-jacente. Un consommateur C peut s’y lier ; aucun contrat ne l’en empêche, et aucun ne l’en protège. C’est une surface livrée que rien ne garde.- Les dix-huit
_Unwind_*sont attendus : le dérouleur d’exceptions accompagne toutecdylib. Ils devront être exclus sciemment, par une liste nommée — jamais par un filtre qui devinerait. air_mainest déclaré et non exporté, et c’est correct. Le header documente ici un contrat en sens inverse : c’est le programme lié qui fournitair_mainau runtime. Un contrôle au niveau des symboles qui l’ignorerait crierait à tort — la vérification a été faite avant d’appeler cela un défaut.
Ces trois faits ne sont pas les mêmes que ceux qu’un versionnement de symboles adresse ; ils disent seulement que la question se pose au niveau des symboles, et qu’on ne peut pas y répondre en lisant des headers.
Ce que le versionnement de symboles apporterait
Sur ELF/GNU, un version script attache une version à chaque symbole exporté
(air_uuid_new_v7@@AIR_1.0). Deux propriétés en découlent, et une seule compte vraiment :
- La coexistence. Deux définitions d’un même nom peuvent vivre dans la même bibliothèque, sous deux versions. Un binaire lié il y a huit ans continue d’appeler la sienne, un binaire neuf appelle la nouvelle. C’est la seule technique connue qui tienne une promesse de dix ans sans figer le code : sans elle, corriger la signature d’une fonction impose de recompiler tout ce qui l’appelle.
- La fermeture.
local: *;rend privé tout ce qui n’est pas explicitement exporté. Cela supprimerait d’un trait les vingt symboles non déclarés delibair_object_c.so— la surface livrée deviendrait, par construction, celle du header.
L’état actuel, mesuré : les .so portent bien une section .gnu.version, mais uniquement
pour les symboles importés de la glibc. Aucun symbole exporté par Air ne porte de
version. Rien n’est amorcé.
Décision demandée
Trois voies, exclusives.
Voie A — Adopter le versionnement de symboles
air-symver (sous-commande cargo xtask check-symver) génère le version script depuis
xtask/abi-reference.toml, l’applique au lien, et vérifie après coup que la bibliothèque
produite porte bien les versions attendues.
- Ce qu’on gagne : la coexistence, donc la possibilité de corriger sans rompre ; et la fermeture de la surface exportée, qui règle mécaniquement les vingt symboles non déclarés.
- Ce qu’on paie : une dépendance au comportement du lien. À valider sur la cible
linux-airavant de s’engager — c’est notre propre chaîne, et notre propre libc est elle-même une bibliothèque partagée. Le risque n’est pas théorique : un mécanisme de versionnement qui ne fonctionnerait qu’engnuserait une garantie qui s’évapore exactement là où Air livre. - Ce qu’il faut trancher en plus : le schéma des noms de version (
AIR_1.0par bibliothèque, ou une version commune à tout Air) et le moment du premier gel.
Voie B — Refuser le versionnement, vérifier la cohérence de version
air-symver se borne à déduire du diff ABI le changement de version exigé — ajout ⇒ mineure,
rupture ⇒ majeure et changement de SONAME — et à échouer si le numéro porté ne suit pas.
- Ce qu’on gagne : aucune dépendance au linker, la vérification marche partout, tout de suite.
- Ce qu’on paie : aucune coexistence. Toute rupture impose une majeure, et tous les consommateurs recompilent. Sur dix ans, cela revient à ne jamais rompre — ce qui est tenable, mais doit être dit, car c’est alors le code qui est figé, pas seulement l’ABI.
- Les vingt symboles non déclarés de
libair_object_c.sorestent à traiter séparément.
Voie C — Différer, et le dire
On ne fait ni l’un ni l’autre maintenant ; ADR-012 et macro-architecture-fr.md sont corrigés
pour dire que air-symver est spécifié et non livré, et l’ADR reste ouvert.
- Ce qu’on gagne : rien n’est engagé à tort, et les textes cessent de mentir.
- Ce qu’on paie : la promesse de dix ans reste tenue par la seule discipline de ne pas rompre, sans filet au niveau des symboles.
La validation exigée, et ce qu’elle a rendu
La décision conditionnait la voie A à une mesure préalable. Elle a été faite, et elle a rendu autre chose que ce qu’on attendait — c’est précisément pourquoi elle était exigée.
Fait 1 — Air ne livre aucune bibliothèque partagée
Les quatre crates *-c portent en toutes lettres « HOST-ONLY : jamais buildée pour
*-linux-air » (ADR-102/ADR-029), et
aucune .so n’est empaquetée : les livrables sont des exécutables static-PIE. Les .so
existent pour être exercées par les tests de conformité C, sur l’hôte.
La mesure « sur linux-air » ne peut donc pas se faire, faute d’objet à mesurer. Ce n’est pas
un échec de la voie A : c’est un changement de ce qu’elle signifie aujourd’hui.
Fait 2 — rustc possède le script de version
Le lien honore bien un script (AIR_1.0 apparaît dans .gnu.version_d). Mais la trace du lien
montre que rustc passe déjà le sien :
-Wl,--version-script=target/debug/deps/rustcXXXXXX/list
GNU ld fait l’union des global: de tous les scripts. Conséquence mesurée sur
libair_object_c.so : notre script ne peut ni retirer un symbole de la surface — même en le
nommant explicitement sous local: (les 19 air_value_* restent exportés) — ni lui attacher
une version : les symboles retombent sur BASE, sans version.
-Wl,--exclude-libs,ALL masque bien les 19, et aussi les 27 air_object_* : cette crate
est un mince ré-export dont les propres symboles viennent eux aussi d’une archive. Trop brutal.
Fait 2 bis — sept écarts que cette mesure avait masqués (correction du 2026-08-14)
La ligne libair_c.so ci-dessus a d’abord été écrite « 18 _Unwind_* + air_main ». C’est
faux : le dérouleur n’en fournit que onze. Les sept autres sont des symboles d’Air que
air_c.h ne déclare pas :
air_fmt_arg_kinds air_fmt_render_buf air_fmt_render_stream
air_libc_fcntl air_libc_open air_libc_openat air_libc_stdio_flush_all
Ils ont été trouvés par le niveau symboles de check-abi, écrit dans la foulée de cet ADR.
L’approximation initiale — avoir rangé tout l’écart sous « dérouleur » sans le compter — les
avait rendus invisibles. C’est exactement le motif que la règle NO GO vise, produit ici par
l’instruction même qui la sert.
Conséquence : ces sept-là sont à trancher au même titre que les vingt de
libair_object_c.so — entrer au header, ou cesser d’être exportés. Ils sont pour l’heure
consignés au cliquet de xtask/abi-reference.toml : tolérés parce qu’inventoriés, un
nouveau serait refusé.
Fait 3 — .symver fonctionne, et la coexistence est prouvée
La technique qu’emploie la glibc contourne l’obstacle : la version s’attache au niveau de
l’objet, par des directives .symver, notre script définissant les nœuds de version.
#![allow(unused)]
fn main() {
core::arch::global_asm!(
".symver air_exemple_v1, air_exemple@AIR_1.0",
".symver air_exemple_v2, air_exemple@@AIR_1.1",
);
}
Résultat mesuré — deux versions du même nom dans une seule bibliothèque :
air_exemple@AIR_1.0 T
air_exemple@@AIR_1.1 T (défaut)
Et la propriété entière, éprouvée par deux consommateurs C liés à la même bibliothèque :
| Consommateur | Lié à | Rend |
|---|---|---|
| neuf | air_exemple@AIR_1.1 | 2 |
| ancien, épinglé | air_exemple@AIR_1.0 | 1 |
C’est la promesse de dix ans, constatée : le binaire d’hier obtient toujours son implémentation, tandis que le code évolue.
Réserve honnête : les symboles porteurs (air_exemple_v1, air_exemple_v2) restent eux
aussi exportés, rustc les listant. La mise en œuvre devra les nommer de façon à ce qu’ils ne
fassent pas partie du contrat — ou accepter qu’ils en fassent partie, ce qui serait un choix,
pas un oubli.
Recommandation
Voie A retenue, mise en œuvre DIFFÉRÉE, et bordée. Le raisonnement tient en trois pas.
- Le versionnement achète une chose : la coexistence. Elle ne protège personne tant
qu’aucune
.son’est livrée, et Air n’en livre aucune. Le construire aujourd’hui, ce serait le construire sur des artefacts hôtes qui n’existent que pour faire tourner des tests. - Mais différer naïvement est dangereux : le versionnement doit exister dès la première
.sopubliée. Attacher des versions à des symboles qui n’en portaient pas est soi-même une rupture de liaison. Une première bibliothèque livrée sans versions rendrait la voie A inatteignable sans casser ce qu’elle protège. - Donc : prouver la technique (fait — §Fait 3), border le report par un gate qui
refuse toute
.soempaquetée sans versions de symboles, et mettre en œuvre au premier.soréellement livré surlinux-air.
La condition de reprise est ainsi nommée et outillée, non confiée à la mémoire.
Conséquences
- Un gate d’empaquetage refuse toute bibliothèque partagée livrée dont les symboles ne portent pas de version. C’est lui qui rend le report sûr : le mécanisme peut attendre, la garantie non.
- Les vingt symboles non déclarés de
libair_object_c.socessent d’être exportés (décision BDFL du 2026-08-14).libair_value_c.soles exporte déjà correctement, 20 sur 20 ; les déclarer une seconde fois engagerait les mêmes symboles par deux bibliothèques pendant dix ans, avec deux définitions à tenir cohérentes et des doublons pour qui lie les deux. Suppose quelibair_object_c.sose lie àlibair_value_c.soau lieu d’embarquer sarlib— chantier de structure qui revient sur le découpage host-only d’ADR-102/029. - Quelle que soit la voie,
check-abigagne un niveau symboles : la comparaison des symboles exportés à la référence, avec une liste nommée d’exclusions (_Unwind_*) et une liste nommée des symboles fournis par le consommateur (air_main). Ce n’est pas conditionné à la décision ci-dessus, et c’est ce qui aurait vu les vingt symboles. - Les vingt symboles non déclarés de
libair_object_c.sodoivent être tranchés : soit ils entrent au header (et deviennent contractuels), soit ils cessent d’être exportés. Les laisser exportés-mais-non-déclarés est le seul état qui ne se défend pas. - La règle des zones (
air-stable/air-internal/air-experimental) d’ADR-012 est le prérequis de tout ceci et n’existe pas non plus dans le code. Elle est traitée à part.
Alternatives rejetées
- Déduire les versions automatiquement du numéro de version des crates. Rejeté : la version d’une crate suit son code, pas son contrat ; deux crates peuvent monter de version sans qu’aucun symbole ne bouge, et l’inverse est tout aussi vrai.
- N’exporter qu’un symbole unique de dispatch (une table de fonctions passée au consommateur), qui supprimerait la question. Rejeté : ce n’est plus une ABI C, c’est un protocole ; les consommateurs C attendus (harnais, portages) ne le consommeraient pas.
Licence du document : MPL 2.0