ADR-098 — air-async : combinateurs de concurrence structurée (join/join_all + ensemble dynamique + Notify) et doctrine de multiplexage
Statut : Accepté (2026-07-23, décision BDFL). Complète
ADR-092 (découpage réacteur/exécuteur)
et ADR-091 (motif sans-IO). Déclencheur concret :
l’incrément C.4a d’ADR-097 (redirection de
port direct-tcpip, ssh -L) a exigé une concurrence bidirectionnelle qu’air-async ne
sait pas exprimer proprement aujourd’hui ; le pump a dû bricoler une jointure de deux futures
à la main (poll_fn + pin!). Cet ADR fait remonter cette brique dans air-async, sa vraie
maison, et fixe la doctrine de concurrence des piles réseau d’Air (air-sshd, futur
air-network).
Catégorie : Architecture (couche 2, exécuteur async). Évolution structurante → soumise à ratification BDFL par RFC (ADR-015).
Contexte
air-async (ADR-092) est l’exécuteur couche 2 au-dessus du réacteur couche 1
air-uring. Il expose aujourd’hui :
- les futures-feuilles d’I/O (
Recv/Send/Connect/Accept…), pilotées par io_uring ; spawn(+JoinHandle) etspawn_on(pool multi-worker) ;timeout,sleep,interval,block_on.
Il manque toute la couche des combinateurs de concurrence : pas de join/join_all/
try_join, pas d’ensemble dynamique type FuturesUnordered, pas de Notify, pas de Mutex
async, pas de channel async. Or toute pile réseau non triviale doit faire progresser
plusieurs flux d’I/O à la fois au sein d’une même connexion :
ssh -L(C.4a) : relayerclient → cibleetcible → clientsimultanément.ssh -R/ multiplexage (C.4b/C.5) : un accept-loop et N canauxforwarded-tcpip/ session, chacun bidirectionnel, tous vivants en même temps.
Trois contraintes dures découvertes en C.4a
recvn’est PAS cancel-safe.recv_packetattend unRECVio_uring ; annuler la future (la dropper) faitforgetl’op en vol → si le noyau a déjà déposé des octets, ils sont perdus. Conséquence : on ne peut pas bâtir la concurrence sur unselectqui annule le perdant (motif « race »). Il faut joindre (attendre que tous finissent), pas sélectionner.spawnexige'static. Une tâchespawnée ne peut rien emprunter ; partager le transport/les sockets imposeraitRc<RefCell<…>>partout — la vérification d’emprunt passe du compile-time au runtime (paniqueRefCellsur aliasing), à rebours de Rust.JoinHandledroppé n’annule PAS la tâche (vérifié dans le code). Les tâchesspawnsont détachées : elles tournent jusqu’au bout. À la fermeture d’une connexion, une tâche-canal bloquée enreadne s’arrête pas seule ; il faut la débloquer explicitement (shutdown des sockets). Surface à fuites de tâches.
Faute de ces primitives, C.4a a inliné une jointure de deux futures dans air-sshd
(crates/air-sshd/src/direct_tcpip.rs, fonction pump), avec un lien de doc mort vers un
join2 inexistant. Ce code ne devrait pas vivre dans air-sshd : c’est une brique
d’exécuteur, réutilisable par toutes les piles réseau, et qui doit être testée (loom + fuzz)
là où vivent les autres primitives async.
Décision
1. Doctrine de concurrence des piles réseau Air : spawn au bord, structurée dedans
Les deux modèles de concurrence ne s’opposent pas ; ils composent à deux granularités :
| Granularité | Mécanisme | Justification |
|---|---|---|
| Par connexion (grain grossier) | spawn / spawn_on (pool) | Une connexion possède tout son état → 'static naturel (zéro emprunt inter-connexions). Fournit le multi-cœur (un réacteur par worker). |
| Dans une connexion (grain fin : canaux, directions, accept-loop) | Concurrence structurée (join_all / ensemble dynamique) | Les canaux empruntent l’état de LEUR connexion → borrow-checker au compile-time, aucun Rc<RefCell>, annulation structurée (dropper le parent droppe les enfants). |
C’est le modèle des serveurs mûrs : une tâche par connexion (répartie sur les cœurs) +
concurrence structurée à l’intérieur. Le multi-cœur est fourni par le pool de workers
(spawn_on), pas par un spawn par canal — les deux axes (parallélisme inter-connexions /
concurrence intra-connexion) sont orthogonaux.
Interdit de doctrine : spawn par canal/direction avec état partagé en Rc<RefCell>
(concurrence fine par tâches détachées). C’est la voie qui échange la sûreté compile-time contre
des paniques RefCell runtime et des tâches orphelines à la fermeture — rejetée.
2. Ce qu’air-async gagne (l’incrément qui débloque C.4b/C.5)
Par ordre de priorité :
join/join_all/try_join— joindre un ensemble fixe de futures empruntantes jusqu’à ce que toutes finissent (généralise lejoin2bricolé).try_joincourt-circuite sur la première erreur (utile aux handshakes). Implémentation :pollde chaque future épinglée ;join_allsurVec<Pin<Box<F>>>(boxing, zérounsafe).- Un ensemble concurrent dynamique (rôle
FuturesUnordered) — pousser des futures au fil du temps (N canaux qui naissent) et les faire progresser ensemble, en retirant les finies. Version naïve d’abord (re-poll de l’ensemble à chaque réveil, O(n)/réveil) : suffisant pour un fan-out borné (poignée de canaux par connexion) ; l’optimisation waker-par-slot (O(1) amorti) est différée jusqu’à mesure (Principe 5). Notify— réveil léger « edge-triggered » (une future écrivain se rendort et est réveillée quand la file sortante reçoit un message). Brique de la sérialisation d’écrivain.
Sérialisation de l’émetteur partagé (N canaux → un seul SendHalf sur le transport chiffré,
un seul .await d’écriture à la fois) : une future écrivain draine un RefCell<VecDeque<…>>
réveillé par Notify. En mono-thread (un réacteur = un thread), on n’emprunte la file que le
temps d’un push/pop — jamais à travers un .await — donc pas besoin de Mutex.
3. Hors-périmètre (délibérément) de cet ADR
Mutexasync / channelmpscasync : utiles pour un modèle acteur inter-connexions (p. ex. AirCom) ou pour B au seul grain connexion ; non nécessaires au pump (la future-écrivain +RefCell<VecDeque>+Notifysuffit en mono-thread). À traiter par un ADR distinct si un besoin réel émerge — pas par anticipation (Principe 5).- Ensemble dynamique O(1) (waker-par-future intrusif) : différé jusqu’à preuve de coût.
selectcancel-safe / lecture cancel-safe (retenir les octets d’unRecvdroppé) : changement lourd du modèle orphelin d’air-uring(ADR-028) ; non requis dès lors qu’on joint au lieu de sélectionner. Consigné comme piste, non retenu.
4. Sûreté & tests (couche 1/2 : Principe 1)
- Combinateurs
#![forbid(unsafe_code)]si possible (boxing plutôt que pin-projection manuelle) ; toutunsaferésiduel exige// SAFETY:. loom(concurrence déterministe, déjà pré-câblé — cf. instrumentation couche 1) surNotifyet l’ensemble dynamique (pas de réveil perdu, pas de double-poll d’une future finie).- Fuzz / property-based sur l’ordonnancement de
join_all(invariant : sortie = tuple des sorties, ordre stable ; une future qui panique n’empoisonne pas les autres). - Couverture ≥ 96 % (couche 2) ; viser 100 % sur les combinateurs (logique pure, pas d’I/O).
Conséquences
air-sshdC.4a est refondé surjoin:pumpdevientjoin(client_to_target(...), target_to_client(...)), le lien de doc mortjoin2disparaît, la brique est testée dansair-async.- Le moteur multiplexé de C.4b/C.5 se bâtit sur l’ensemble dynamique + la future-écrivain +
Notify: accept-loop et N canaux joints dans une tâche par connexion,spawn_onau bord pour le multi-cœur. Plus de tâches détachées à débloquer manuellement. - Testabilité : la concurrence des piles réseau devient déterministe et loom-testable,
au lieu d’un
poll_fnad hoc par crate. - Dette adjacente consignée (hors périmètre, à durcir) : le pump de session (C.3) fait
quelques syscalls synchrones sur le thread réacteur —
air_account::lookup_user_by_name(lecture bloquante de/etc/passwd) etAirTaskManager::wait(waitid). Un démon multi-connexions les voudra async :IORING_OP_READpour le fichier,IORING_OP_WAITID(noyau ≥ 6.7) pour un reap non bloquant — additifsair-uringà cadrer séparément.
Alternatives rejetées
- Pur-B (tout en
spawn+Rc<RefCell>+Mutexasync) — vérification d’emprunt repoussée au runtime (paniquesRefCell), tâches détachées orphelines à la fermeture (fait vérifié), plus de primitives à ajouter (Mutex, channel) et plus de cycle de vie manuel. Moins sûr, moins « Rust-friendly ». (Conservé uniquement au grain connexion, où'staticest naturel.) - Continuer à bricoler dans
air-sshd(unpoll_fnpar pile) — duplication, non testé en isolation, mauvaise maison ; viole l’éthos « la brique vit là où vivent ses pairs ». - Concurrence par
select/race — impossible proprement :recvn’est pas cancel-safe (perte d’octets à l’annulation). Rejeté par correction, pas par goût. - Dépendre d’une crate
futures(join!/FuturesUnorderedtout faits) — refusé par la règle des 80 % (ADR-024, Principe 6) : on n’utiliserait qu’une fraction defutures, etair-asyncdoit rester maître de son modèle d’exécution (sans-IO, io_uring,no_std-compat).
Références
- ADR-091 — motif réseau sans-IO (cœur pur / pilote mince).
- ADR-092 — réacteur (couche 1) / exécuteur (couche 2).
- ADR-097 — phase 3
ssh-connection(déclencheur concret, C.4a). - ADR-028 — soundness / modèle d’ownership des ops io_uring
(pourquoi l’annulation
forgetperd les octets). - RFC 4254 —
ssh-connection(le consommateur).