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-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) et spawn_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) : relayer client → cible et cible → client simultanément.
  • ssh -R / multiplexage (C.4b/C.5) : un accept-loop et N canaux forwarded-tcpip/ session, chacun bidirectionnel, tous vivants en même temps.

Trois contraintes dures découvertes en C.4a

  1. recv n’est PAS cancel-safe. recv_packet attend un RECV io_uring ; annuler la future (la dropper) fait forget l’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 un select qui annule le perdant (motif « race »). Il faut joindre (attendre que tous finissent), pas sélectionner.
  2. spawn exige 'static. Une tâche spawnée ne peut rien emprunter ; partager le transport/les sockets imposerait Rc<RefCell<…>> partout — la vérification d’emprunt passe du compile-time au runtime (panique RefCell sur aliasing), à rebours de Rust.
  3. JoinHandle droppé n’annule PAS la tâche (vérifié dans le code). Les tâches spawn sont détachées : elles tournent jusqu’au bout. À la fermeture d’une connexion, une tâche-canal bloquée en read ne 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écanismeJustification
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é :

  1. join / join_all / try_join — joindre un ensemble fixe de futures empruntantes jusqu’à ce que toutes finissent (généralise le join2 bricolé). try_join court-circuite sur la première erreur (utile aux handshakes). Implémentation : poll de chaque future épinglée ; join_all sur Vec<Pin<Box<F>>> (boxing, zéro unsafe).
  2. 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).
  3. 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/popjamais à travers un .await — donc pas besoin de Mutex.

3. Hors-périmètre (délibérément) de cet ADR

  • Mutex async / channel mpsc async : 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> + Notify suffit 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.
  • select cancel-safe / lecture cancel-safe (retenir les octets d’un Recv droppé) : 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) ; tout unsafe résiduel exige // SAFETY:.
  • loom (concurrence déterministe, déjà pré-câblé — cf. instrumentation couche 1) sur Notify et 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-sshd C.4a est refondé sur join : pump devient join(client_to_target(...), target_to_client(...)), le lien de doc mort join2 disparaît, la brique est testée dans air-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_on au 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_fn ad 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) et AirTaskManager::wait (waitid). Un démon multi-connexions les voudra async : IORING_OP_READ pour le fichier, IORING_OP_WAITID (noyau ≥ 6.7) pour un reap non bloquant — additifs air-uring à cadrer séparément.

Alternatives rejetées

  • Pur-B (tout en spawn + Rc<RefCell> + Mutex async) — vérification d’emprunt repoussée au runtime (paniques RefCell), 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ù 'static est naturel.)
  • Continuer à bricoler dans air-sshd (un poll_fn par pile) — duplication, non testé en isolation, mauvaise maison ; viole l’éthos « la brique vit là où vivent ses pairs ».
  • Concurrence par select/raceimpossible proprement : recv n’est pas cancel-safe (perte d’octets à l’annulation). Rejeté par correction, pas par goût.
  • Dépendre d’une crate futures (join!/FuturesUnordered tout faits) — refusé par la règle des 80 % (ADR-024, Principe 6) : on n’utiliserait qu’une fraction de futures, et air-async doit 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 forget perd les octets).
  • RFC 4254ssh-connection (le consommateur).