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

Rapport de design — air-async (runloop io_uring de la couche 2) — INCRÉMENT 0

Statut : rapport de conception + reconnaissance + squelette prouvé. Non engageant au sens ADR (une décision structurante — l’introduction du réacteur couche 1 air-uring et le découpage réacteur/exécuteur — sera gravée par un ADR/RFC avant l’incrément 1). Branche feat/air-async-design.

Amont normatif : ADR-038 (sync-first / async opt-in, io_uring seul moteur, pas de tokio), ADR-091 (motif réseau sans-IO : air-async = le pilote I/O async), ADR-022 (architecture io_uring), ADR-023 (runtime async Air). Complète architecture-prerequis-air-sshd-fr.md (qui identifie air-async comme prérequis manquant de la couche 2).


0. TL;DR — la recommandation

  1. Layering — option (a), et c’est la SEULE cohérente. Scinder le « runloop » en deux crates :

    • air-uring — le réacteur, en couche 1 (rempart safe au-dessus de l’unsafe io_uring de la couche 0, exactement comme air-poll expose ppoll) ;
    • air-async — l’exécuteur (futures, block_on/spawn, timers), en couche 2 (surface applicative async, consommée par le framework réseau).

    C’est le seul découpage qui honore à la fois ADR-038 (qui place le runtime async en couche 2) et le gate cargo xtask check-layers (une couche 2 ne peut pas toucher la couche 0 io_uring). Preuve : check-layers VERT (§4).

  2. Modèle d’exécution (aligné ADR-038) : mono-thread d’abord (un réacteur par thread — l’IoUring couche 0 est Send + !Sync), buffers possédés (déjà garanti par la couche 0 — rien à réinventer), un CQE → un réveil de Waker, timers = IORING_OP_TIMEOUT (io_uring seul, pas de timerfd).

  3. Squelette prouvé (§3) : air-uring::Reactor + air-async::block_on + future Timeout ; 3 tests verts exécutant de vraies opérations IORING_OP_TIMEOUT sur l’hôte gnu. check-layers/clippy -D/fmt propres.

  4. On s’arrête au squelette. L’introduction d’air-uring (nouveau crate couche 1) et le découpage réacteur/exécuteur sont une grosse décision : conformément à la consigne, on livre le design + la preuve minimale et on rapporte — le runtime complet (spawn, roue de timers, annulation, I/O réseau/fichier) n’est pas bâti. Questions ouvertes en §6.


1. §A.1 — Reconnaissance : la surface io_uring de la couche 0

air-sys-syscall::io_uring (couche 0, module scellé couche-0-v1) est riche et directement consommable pour bâtir un réacteur. Tout ce qui suit est safe (l’unsafe — asm des 3 syscalls, protocole d’anneau, mmaps — est confiné dans la crate couche 0).

Cycle de vie du ring. IoUring::new(entries: NonZeroU32) / IoUringBuilder (CQ size, max_inflight, SQPOLL, attach_wq, restrictions sandbox). IoUring est Send + !Sync (ADR-022 D6 : un réacteur par thread). Téardown sound par Drop quiescent + shutdown() (annule + draine, ou fuite contrôlée — jamais d’UAF). Pour le multi-thread : LockedIoUring, RingPool (thread-per-core, io-wq partagé), SqpollIoUring.

Soumission. Pas de builder SQE typé exposé, mais une méthode safe typée par opération, chacune rendant un SubmissionToken : submit_timeout, submit_poll_add, submit_read/submit_write (+ readv/writev), submit_accept/submit_connect/submit_send/submit_receive (+ zero-copy, sendmsg/recvmsg), submit_nop, submit_cancel, submit_close, submit_openat2, submit_statx, xattr, splice/tee… Multishot : submit_*_multishot (accept/recv/read/poll/timeout) rendant un MultishotToken. Options par-op via with(SubmitOptions) (drain/force_async/skip_cqe_on_success). Chaînes liées via LinkedChainBuilder (then/with_link_timeout). Les submit_* mettent en file ; rien ne part avant submit()/submit_and_wait().

Attente / moisson des complétions. submit(&mut) -> u32 (soumet, sans attente) ; submit_and_wait(want) -> u32 (soumet + GETEVENTS, min_complete = want, bloquant) ; wait_completion() -> Completion (bloquant, ≥1) ; wait_completion_timeout(Duration) ; try_completion() -> Option<Completion> (non bloquant, sans syscall) ; completions() -> CompletionIter. Completion expose token(), raw_result() -> i32, flags(), has_more() (multishot CQE_F_MORE), is_notif(), buffer_id(), et des extracteurs typés qui redéplacent le payload possédé (into_result, completed, into_buffer_result -> (Vec<u8>, usize), accepted_fd, into_poll_result…). Convention : res < 0-errno.

Corrélation submit↔complete. SubmissionToken { slot, generation } — opaque (champs pub(crate)), Copy + Eq. Encodé en interne dans le user_data kernel ; la génération filtre les CQE périmés (protection ABA). Point clé : le token ne porte pas de Waker ; c’est au réacteur de mapper SubmissionTokenWaker.

Embarquement dans une boucle hôte. register_eventfd(BorrowedFd) / register_eventfd_async / unregister_eventfd (IORING_REGISTER_EVENTFD) : une boucle externe (epoll/GLib) attend ce seul fd pour savoir qu’Air a du travail prêt — l’interop sans implémenter epoll qu’exige ADR-038 §5.

Timers. submit_timeout(TimeoutSpec, TimeoutFlags) (IORING_OP_TIMEOUT, res == -ETIME à l’expiration), submit_timeout_remove/_update, submit_timeout_multishot(interval) (périodique). TimeoutSpec::after(Duration) / after_completions(count). Tout ce qu’il faut pour des timers sans timerfd.

Ownership des buffers — DÉJÀ RÉSOLU en couche 0. C’est le résultat de recon le plus structurant. La contrainte io_uring « le buffer doit survivre à la complétion » (le modèle owned-buffer de tokio-uring/glommio) est déjà implémentée : submit_read/write/send/receive prennent un Vec<u8> par valeur, garé dans le slab S1 (OwnedOp) jusqu’à la complétion, et restitué par into_buffer_result. Buffers enregistrés (RegisteredBuffers), fournis ring-mapped (ProvidedBufferRing, RAII ProvidedBuffer), et zero-copy (NOTIF différée) sont aussi présents. Conséquence : ni air-uring ni air-async n’ont à réinventer la discipline d’ownership — ils enfilent des buffers possédés à travers les futures.

2. §A.2 — La question de layering (tranchée)

Le fait qui contraint tout

cargo xtask check-layers (xtask/src/check_layers.rs) impose, pour une crate de couche N dépendant d’un air-* de couche M : M ∈ {N-1, N} (pas de dépendance montante, pas de saut de couche). Seul air-sys-types est transverse (exempté) ; air-sys-syscall (io_uring), lui, est couche 0 ordinaire, non exempté. Les arêtes [target.'cfg(…)'.dependencies] ne sont qu’advisory (ADR-087), mais on ne veut pas d’un réacteur cible-only.

Donc :

  • un réacteur qui consomme io_uring (couche 0) doit être couche 1 (1−1=0 ✓) ;
  • air-async en couche 2 ne peut pas dépendre d’air-sys-syscall (2→0 = saut de couche = échec dur sur le graphe hôte).

Or ADR-038 place explicitement le runtime async en couche 2 (« le runloop / runtime asynchrone (air-event, couche 2) »). Tension apparente : le runtime est couche 2, mais il lui faut un accès io_uring couche 0 interdit à la couche 2.

Les options

  • (a) Réacteur couche 1 (air-uring) + exécuteur couche 2 (air-async). Le réacteur (soumission + attente CQE + réveil Waker) est le rempart safe couche 1 sur io_uring couche 0 ; l’exécuteur (futures, block_on/spawn, timers) est la surface applicative couche 2, consommée par le framework réseau. check-layers : air-uring(1)→air-sys-syscall(0) ✓ ; air-async(2)→air-uring(1) ✓.
  • (b) Tout (réacteur + exécuteur) en couche 1. Un seul crate couche 1. check-layers passe aussi (1→0 ✓). Mais contredit ADR-038 (runtime en couche 2) et brouille la doctrine : la couche 1 est le rempart fidèle et mince sur les syscalls (air-poll, air-socket) — y loger une politique d’ordonnancement de tâches (spawn, réveils, roue de timers, annulation) mêle deux natures et alourdit le tier fondateur.
  • (c) Autre découpage (réacteur dans air-runtime, etc.). Rejeté : air-runtime est le crt0/TLS/threads (« pas un scheduler de futures ») ; y greffer un réacteur le dénaturerait.

Recommandation : (a)

Option (a) est la seule qui satisfait simultanément :

Contrainte(a)(b)(c)
check-layers VERT (graphe hôte)✅/❌
ADR-038 : runtime async en couche 2
Doctrine : couche 1 = rempart safe/mince sur couche 0
air-runtime reste le crt0, pas un scheduler
Cœurs de protocole (couche 2) consomment l’API async (2→2)

Le découpage est naturel : air-uring fait la même chose pour io_uring que air-poll pour ppoll (exposer safement un mécanisme d’attente couche 0). air-async est le pilote I/O async d’ADR-091 que les cœurs sans-IO consomment. La frontière est nette : air-uring possède tous les types io_uring et ne rend à la couche 2 que des types couche 1 / core (jetons opaques OpToken, résultats i32/Errno, buffers Vec<u8>). air-async ne nomme jamais air_sys_syscall — check-layers vert et abstraction honnête.

Nuance ADR-038. ADR-038 nomme le runtime air-event (couche 2) ; ADR-091 et la présente reconnaissance l’appellent air-async. Divergence de nommage à réconcilier par l’ADR (§6, Q1). On adopte air-async (mission + ADR-091, plus récent). La substance d’ADR-038 (runtime en couche 2) est respectée : l’exécuteur air-async est en couche 2 ; seul le réacteur descend en couche 1, ce qu’ADR-038 n’interdit pas (il ne dit pas vit la mécanique io_uring, seulement où vit le runtime).

3. §A.3 / §B — Modèle d’exécution + squelette prouvé

Aligné ADR-038 et prouvé par le squelette (crates/air-uring, crates/air-async).

  • Mono-thread d’abord. IoUring est Send + !Sync ⇒ un réacteur par thread. Le squelette est mono-thread ; le thread-per-core (via RingPool/LockedIoUring couche 0 + vol de tâches) est un incrément ultérieur. Le réacteur est partagé avec les futures par Rc<RefCell<Reactor>> (ReactorHandle) — les emprunts ne se chevauchent jamais dans le temps (le poll rend la main avant chaque turn).
  • Buffers possédés. Les futures I/O prendront/rendront des Vec<u8> possédés, directement portés par le slab S1 de la couche 0 — modèle owned-buffer de tokio-uring, gratuit ici. (Le squelette exerce IORING_OP_TIMEOUT, sans buffer ; la future Read/Recv suivra le même patron avec into_buffer_result.)
  • Un CQE → un réveil. Reactor tient SubmissionToken → (Waker, Option<res>). Reactor::turn() : submit_and_wait(1) (soumet la file + bloque), puis draine la CQ non bloquant (try_completion), range chaque res sous son jeton et réveille le Waker enregistré. block_on re-poll sur réveil.
  • Timers = IORING_OP_TIMEOUT. ADR-038 §3 : io_uring seul moteur (readiness et timeouts), pas de timerfd, pas d’epoll. La future Timeout arme un submit_timeout(TimeoutSpec::after(d)) ; l’expiration (res == -ETIME) est le succès nominal. Roue de timers / submit_timeout_multishot = incrément ultérieur.
  • Annulation. submit_cancel/submit_timeout_remove existent ; la sûreté d’annulation au drop d’une future (garder le buffer vivant jusqu’au CQE d’annulation) est une question ouverte dure (§6, Q3) — non traitée dans le squelette.

Anatomie visée (le pilote I/O du motif sans-IO, ADR-091)

air-async est le pilote I/O async (l’étage « mince » d’ADR-091) : il câble un cœur sans-IO (parse/état, pur, couche 2) à io_uring. Les futures d’air-async (Read/Write/Accept/Connect/Timeout) alimentent en octets un cœur qui n’a aucune notion de socket ni d’horloge réelle. Neutralité sync/async (ADR-091 §6) : le même cœur se pilote en synchrone (air-socket) ou en async (air-async) — le choix vit dans le pilote, pas dans le cœur.

Squelette livré et preuve (hôte gnu, io_uring réel)

crates/air-uring (couche 1) — Reactor : new, arm_timeout(dur, &Waker), set_waker, take_result, turn, tracked. no_std + alloc, zéro unsafe exposé, zéro dépendance externe.

crates/air-async (couche 2) — reactor(entries), block_on(&handle, future), future Timeout. std (pilote I/O). Zéro dépendance externe ; ne nomme aucun type couche 0 io_uring (seul Errno, transverse).

$ cargo test -p air-uring
    test tests::timeout_op_completes_and_wakes ... ok      # réacteur : IORING_OP_TIMEOUT 50 ms → CQE → Waker réveillé → res=-ETIME

$ cargo test -p air-async
    test tests::block_on_timeout_completes ... ok          # exécuteur : block_on(Timeout 50 ms) via réacteur io_uring
    test tests::block_on_two_sequential_timeouts ... ok    # 2 timeouts séquentiels (corrélation multi-jetons)

Chemin prouvé de bout en bout : exécuteur (L2) → réacteur (L1) → io_uring (L0) → CQE → réveil du Waker → re-poll → Ready.

4. §B — check-layers VERT (preuve du layering)

$ cargo xtask check-layers
▌ check-layers — WARN
  couches conformes (1 advisory cible-only)
      crates air-* scannées = 52   couche 0 = 2   couche 1 = 25   couche 2 = 25
  ⚠ air-libc-capi (layer 2) -> air-sys-syscall (layer 0) : … [cible-only … ADR-087 : advisory]
  ✓ graphe de couches conforme (règles a/b/c) — 52 couche(s) déclarée(s), 1 advisory cible-only

Les deux nouvelles arêtes — air-uring(1)→air-sys-syscall(0) et air-async(2)→air-uring(1) — sont conformes (passent en silence). L’unique advisory (air-libc-capi, cible-only) préexiste et n’est pas lié à ce travail. cargo fmt --check et cargo clippy -p air-uring -p air-async --all-targets -- -D warnings sont propres.

5. Ce qui N’EST PAS fait (périmètre du squelette)

Volontairement hors incrément 0 (bâtir le runtime complet contredirait la consigne « s’arrêter au design sur grosse décision ») : exécuteur multi-tâches (spawn, file de tâches prêtes) ; roue de timers / timeout_multishot ; futures I/O réseau/fichier (Read/Write/Accept/Connect, buffers possédés) ; sûreté d’annulation au drop ; multishot (accept/recv) + provided buffers ; éveil inter-thread (eventfd + msg_ring pour nudger un submit_and_wait bloqué) ; thread-per-core (RingPool) ; intégration as_fd() d’air-socket et register_eventfd pour boucles hôtes ; API C-ABI (toit couche 2).

6. Questions ouvertes (pour l’ADR / l’incrément 1)

  • Q1 — Nommage. air-event (ADR-038) vs air-async (ADR-091/mission). Trancher et amender l’ADR. Et : le réacteur couche 1 s’appelle-t-il air-uring, air-reactor, air-ring ? (proposé : air-uring, par mimétisme d’air-poll).
  • Q2 — Un ADR dédié ? Graver le découpage réacteur(L1)/exécuteur(L2) et la frontière « la couche 1 possède tous les types io_uring » dans un ADR (amende/complète ADR-038 et ADR-023).
  • Q3 — Sûreté d’annulation. Quand une future I/O est droppée avant sa complétion, le buffer possédé (dans le slab S1) doit rester vivant jusqu’au CQE d’annulation. Politique : submit_cancel au drop + rétention du slot ? S’appuyer sur le téardown quiescent de la couche 0 ? À concevoir (c’est le piège io_uring).
  • Q4 — Corrélation token→tâche. Le squelette balaie un Vec linéairement. La production doit indexer par le slot du jeton (O(1)). Faut-il qu’air-uring expose une table indexée, ou que la couche 0 rende le slot public ? (RFC couche 0 scellée si besoin).
  • Q5 — Éveil inter-thread & mono-vs-multi-thread. Modèle de réveil quand un Waker est déclenché depuis un autre thread (channel) alors que le réacteur est bloqué dans submit_and_wait : eventfd enregistré + msg_ring/NOP ? Et quand bascule-t-on vers thread-per-core (RingPool) ?
  • Q6 — Intégration boucle hôte. Exposer register_eventfd + le seam as_fd() d’air-socket pour l’embarquement (ADR-038 §5) : à quel niveau d’API ?
  • Q7 — Back-pressure de soumission. EBUSY (SQ/slab pleins) : le réacteur bufferise-t-il les ops en excès, ou la future retourne-t-elle Pending jusqu’à libération d’un slot ? Politique explicite à définir (ADR-091 §8 : back-pressure explicite).

INCRÉMENT 1 — sûreté d’annulation + exécuteur multi-tâches + 1ʳᵉ future réseau

Statut : implémenté et prouvé sur carbon (x86_64, host-first, ring io_uring réel sur la cible gnu). Branche feat/air-async-inc1. Répond aux questions ouvertes Q3 (sûreté d’annulation) et Q4 (corrélation token→tâche), et livre la première I/O réseau async d’Air.

I1.1 — Sûreté d’annulation io_uring (la priorité — ADR-028)

Le piège et pourquoi Air y échappe par construction

io_uring est completion-based : le kernel garde un pointeur vers le buffer d’une op jusqu’au CQE. En Rust async, une Future peut être droppée à tout moment (select!, timeout, annulation). Libérer le buffer pendant que l’op est en vol ⇒ le kernel écrit dans de la mémoire libérée ⇒ use-after-free piloté par le noyau (le défaut classique de tokio-uring/glommio).

Air neutralise ce piège en deux étages :

  • Couche 0 (ADR-028 S1/S2) — l’ownership du buffer. Les submit_* prennent un buffer possédé (Vec<u8>) et le déplacent dans le slot du slab ; il n’en ressort qu’au CQE (try_completion le rend dans une Completion). Conséquence décisive : tant qu’une op est en vol, rien au-dessus de la couche 0 ne possède le buffer. Aucune future de couche 1/2 ne peut donc le libérer — le dropper est structurellement impossible, pas seulement « évité ». La destruction du ring lui-même est quiescente (S2 : annule + draine avant de rendre la mémoire).

  • Couche 1 (air-uring, cet incrément) — le mécanisme d’orphelin. L’étage 0 garantit la sûreté mémoire ; il reste un problème de liveness / non-fuite : une op qui ne se complète jamais seule (recv sur socket muet, accept sans connexion) laissée par une future droppée occuperait son slot pour toujours (fuite → EBUSY), et son buffer resterait indéfiniment détenu. Le réacteur résout cela sans jamais libérer prématurément :

    1. la future d’I/O, dans son Drop (si son op est encore en vol), appelle Reactor::forget(token) — elle ne libère rien ;
    2. forget marque le slot de suivi orphelin (plus de Waker) et soumet un IORING_OP_ASYNC_CANCEL ciblant le jeton, puis laisse l’op en vol ;
    3. au CQE correspondant (complétion nominale ou -ECANCELED), Reactor::turn réconcilie l’orphelin : il libère le slot et laisse tomber la Completion — c’est là, et seulement là, que le buffer possédé (côté couche 0) est relâché.

    Si l’op était déjà complétée au moment du forget (CQE arrivé, résultat non encore récupéré), le slot est simplement libéré et la Completion droppée : l’op ayant déjà quitté le kernel, relâcher le buffer est sûr, sans annulation.

Invariant net : aucune libération de buffer ne précède le CQE, jamais — que la future vive, soit droppée en vol, ou que le ring soit détruit. Le forget best-effort (si la SQ/le slab sont pleins, l’annulation échoue silencieusement) reste sûr : l’orphelin demeure tracé et sera drainé au pire par le téardown quiescent du ring (S2). On ne panique jamais dans un Drop.

Table des orphelins & anti-ABA

Le réacteur tient un slab de slots de suivi couche 1 (Vec<Slot> + free-list). Chaque slot porte : le SubmissionToken couche 0 (clé de corrélation CQE), le Waker, un drapeau orphaned, et la Completion rangée. À chaque libération, la génération du slot est incrémentée ; l’OpToken opaque rendu à la couche 2 embarque (token, slot, génération). Un jeton périmé (slot réutilisé) est rejeté par vérification de génération — un forget/take tardif ne peut jamais être confondu avec l’op courante (anti-ABA), en plus de l’anti-ABA de la couche 0.

Preuve de soundness (drastique)

  • Test drop-en-vol (air-uring, drop_in_flight_defers_buffer_release_until_cqe)
    on arme un recv sur une paire de sockets muette (ne complète jamais seule), on le pousse réellement en vol (flush, kernel_in_flight ≥ 1 ⇒ buffer vivant dans le slab couche 0), on oublie l’op (comme un Drop de future) ; on prouve que l’orphelin reste suivi (buffer non relâché), qu’une annulation est soumise, puis qu’au turn l’orphelin est réconcilié et le buffer relâché exactement au CQE (tracked == 0, kernel_in_flight == 0).
  • Stress déterministe (cancellation_stress_no_leak) : 200 cycles arm-recv-en-vol → forgetturn ; le slab couche 1 se recycle intégralement (zéro fuite de slot), le kernel revient quiescent.
  • Anti-ABA (stale_token_is_rejected) : un jeton d’op récupérée n’agit jamais sur l’op ultérieure qui réutilise le slot.
  • Couche 2 (dropped_accept_future_is_forgotten) : un accept mis en course avec un timeout (select-like) est droppé en vol quand le timeout gagne ; il est annulé et libéré — pas de fuite jusqu’à la couche applicative.
  • Valgrind memcheck (Miri ne supporte pas les syscalls io_uring) : les binaires de test d’air-uring et d’air-async passent sous valgrind --leak-check=full --error-exitcode=99ERROR SUMMARY: 0 errors, definitely lost: 0 bytes. Zéro UAF, zéro fuite définitive sur les scénarios drop-en-vol répétés et le chemin réseau complet. (Le seul « possibly lost » observé provient de l’infrastructure de threads du harnais de test std, pas du réacteur. Sous valgrind, lancer les tests en --test-threads=1 : la borne temporelle haute du test de concurrence est sensible au ralentissement ~20× de l’instrumentation.)

I1.2 — Exécuteur multi-tâches (air-async, couche 2)

  • Runtime : spawn(future) -> JoinHandle<T> + block_on(root). Boucle d’exécution : polle la racine et les tâches spawnées dont le drapeau de réveil est levé, puis park sur le réacteur (turn = submit_and_wait + drain) quand tout est Pending. Un CQE réveille le Waker de la tâche concernée (⇒ drapeau levé) ; le tour suivant la re-polle. block_on (fonction libre) reste la variante mono-future.
  • Waker par tâche : Arc<TaskWaker> (drapeau atomique). Send + Sync (exigé par std::task::Wake) bien qu’on reste mono-thread ; le CQE d’une op réveille toujours sa tâche d’origine, jamais une autre. Les Drop de futures s’exécutent hors de tout emprunt du réacteur (la tâche est sortie de la table avant son poll), donc forget au Drop n’entre jamais en collision RefCell.
  • JoinHandle<T> : await-able ; la glue de spawn capture la sortie et réveille le joignant.
  • Corrélation token→tâche (Q4) : récupération / oubli / re-Waker en O(1) par indexation directe du slot (l’OpToken porte l’index). La réconciliation CQE→slot dans turn reste un balayage borné (par la concurrence de pointe) : un O(1) strict à ce point exigerait d’exposer le slot du SubmissionToken depuis la couche 0 scellée — dette explicitement assumée (voir « Suites »), jamais contournée par un raccourci.
  • Timers : IORING_OP_TIMEOUT per-op conservé (suffisant et prouvé) ; la roue de timers reste un raffinement ultérieur (non requis fonctionnellement ici).

I1.3 — Première future réseau réelle (§3)

air-uring expose arm_accept / arm_connect / arm_socket / arm_receive / arm_send (rempart safe, aucun type io_uring fuité). air-async en tire des futures ergonomiques — TcpListener::accept, TcpStream::connect, TcpStream::read / write — chacune cancel-safe (leur Drop appelle forget). Buffers restitués intacts par transfert d’ownership (couche 0).

Preuve — echo TCP async en loopback (tcp_echo_async_loopback) : un TcpListener async accept, lit une ligne, la réécrit ; un client connecte (socket + connect async), envoie, relit. Serveur et client vivent dans deux tâches spawnées concurrentes : l’accept du serveur ne peut aboutir que si le connect du client progresse en parallèle — ce qui exige l’exécuteur multi-tâches. Assertion : l’écho reçu est bit-pour-bit identique à l’émis (charge utile incluant de l’UTF-8 multi-octets). Prouve donc exécuteur multi-tâches + I/O réseau async de bout en bout. spawn_runs_tasks_concurrently prouve en outre le recouvrement temporel (deux timeouts de 60 ms ⇒ ~60 ms, pas ~120 ms).

I1.4 — Layering & qualité

  • cargo xtask check-layers VERT : air-uring = 1, air-async = 2, aucun saut (le seul advisory est préexistant, air-libc-capi, cible-only ADR-087). air-async ne nomme aucun type io_uring (vérifié : seules des mentions en commentaire/opcode subsistent).
  • cargo fmt --check, cargo clippy -D warnings (dont arithmetic_side_effects, cast_possible_truncation), cargo test -p air-uring -p air-async : verts. air-uring reste #![no_std] + alloc (build non-test OK) ; zéro unsafe exposé (tout l’unsafe io_uring reste confiné en couche 0).

I1.5 — Ce qui reste (suites)

  • Q4 (résiduel) — O(1) strict de la réconciliation turn : exposer le slot du SubmissionToken (couche 0 scellée → BDFL/RFC), ou une table indexée côté couche 0.
  • Q5 — éveil inter-thread (eventfd enregistré + msg_ring) et bascule thread-per-core (RingPool).
  • Q7 — politique de back-pressure explicite sur EBUSY (SQ/slab pleins) : aujourd’hui l’arm_* remonte l’Errno ; à raffiner en Pending/ré-essai.
  • Roue de timers ; multishot accept/recv (Temps 3d couche 0) ; read/write fichiers async ; intégration boucle hôte (register_eventfd).

INCRÉMENT 2 — robustesse : éveil inter-thread, roue de timers, back-pressure, I/O fichier

Statut : implémenté et prouvé sur carbon (x86_64, host-first, ring io_uring réel sur la cible gnu). Branche feat/air-async-inc2. Durcit le runtime vers un usage réel avant l’IPC, en répondant aux questions ouvertes Q5 (éveil inter-thread) et Q7 (back-pressure), et en ajoutant timers mutualisés et I/O fichier. La sûreté d’annulation de l’incrément 1 reste prouvée (tests drop-en-vol conservés + valgrind vert).

I2.1 — Éveil inter-thread (§1, le plus structurant)

Le problème

Un Waker de l’incrément 1 ne levait qu’un drapeau atomique : la boucle de l’exécuteur re-pollait la tâche au tour suivant. Cela suffit tant que le réveil survient sur le thread du réacteur. Mais un Waker déclenché depuis un autre thread OS (un canal alimenté par un thread de calcul, un JoinHandle attendu d’ailleurs) trouve le réacteur endormi dans le kernel (submit_and_wait) : lever un drapeau en mémoire ne le réveille pas → deadlock.

Mécanisme retenu — eventfd en op de lecture permanente

Options envisagées (mission) : (a) un eventfd que le réacteur tient en op et qu’un réveil externe écrit ; (b) IORING_OP_MSG_RING depuis un ring émetteur. On retient (a), l’eventfd, car il ne suppose pas que le thread émetteur possède lui-même un ring (un simple write(8 octets) suffit), là où msg_ring exigerait un IoUring par thread réveilleur. msg_ring reste le bon choix pour un futur thread-per-core (ring↔ring) ; il n’est pas nécessaire ici.

Concrètement, en couche 1 (air-uring) :

  • le Reactor crée un eventfd (compteur, CLOEXEC|NONBLOCK) et tient dessus une lecture permanente (IORING_OP_READ de 8 octets, ré-armée à chaque CQE). Tant que cette lecture est en vol, toute écriture sur l’eventfd complète la lecture ⇒ submit_and_wait revient ;
  • Reactor::wake_handle() rend un WakeHandle Send + Sync clonable (face « écriture », un Arc<EventFd>). WakeHandle::wake() écrit 1 — best-effort, jamais de panique (idéal dans un chemin de réveil). L’eventfd est le seul type couche 0 exposé (transverse) ; aucun type io_uring ne fuite ;
  • la lecture d’eventfd est interne : elle n’occupe pas de slot de suivi couche 1 (reconnue par son jeton dans le drainage, ré-armée, jetée), et kernel_in_flight() l’exclut (métrique « ops utilisateur ») — les tests de soundness inc 1 restent donc exacts (kernel_in_flight == 0).

En couche 2 (air-async), un Parker partagé par tous les TaskWaker porte : le WakeHandle, un drapeau parked et l’ThreadId propriétaire. TaskWaker::signal lève le drapeau de tâche puis, si et seulement si le réveil vient d’un autre thread et que l’exécuteur est parké, nudge l’eventfd. Le test « autre thread » élimine tout write superflu sur le chemin mono-thread chaud (le réacteur étant !Send, son thread est fixe) ; le test « parké » évite de nudger quand la boucle reprendra la main d’elle-même.

Anti-perte de réveil (handshake SeqCst)

Course redoutée : l’exécuteur décide de parquer pendant qu’un autre thread réveille. drive annonce parked = true puis re-vérifie la disponibilité (drapeaux) avant de bloquer ; le Waker, symétriquement, publie la disponibilité puis lit parked. Les quatre accès (parked store/load, ready store/load) sont en SeqCst : leur ordre total garantit qu’au moins l’un des deux voit l’autre (argument de type Dekker). Aucun réveil perdu, aucun busy-loop (le park est un vrai submit_and_wait bloquant).

Preuve

air-uring : wake_handle_unparks_turn_from_another_thread — sans op utilisateur, turn bloquerait indéfiniment ; un autre thread écrit l’eventfd ⇒ turn revient. air-async : cross_thread_wakeup_unparks_executor (canal) et cross_thread_wakeup_via_join_handle (JoinHandle). La terminaison de ces tests EST la preuve — sans le mécanisme, ils deadlockent.

I2.2 — Roue de timers (§2)

L’incrément 1 armait un IORING_OP_TIMEOUT par sleep (N sleeps ⇒ N ops kernel). L’incrément 2 introduit une roue de timers (tas binaire par échéance) en couche 2 (politique d’ordonnancement, crates/air-async/src/time.rs) servie par un unique timeout kernel :

  • sleep(Duration), interval(Duration) (échéances absolues, sans dérive), timeout(future, Duration) (combinateur) ;
  • l’exécuteur, à chaque park, fait feu tous les timers échus (deadline ≤ now) puis (ré)arme l’unique timeout kernel sur l’échéance la plus proche (annulant le précédent via forget, cancel-safe) ;
  • anti-obsolescence par génération de slot (un timer annulé libère son slot ; les entrées de tas périmées sont filtrées à la volée — même doctrine que l’anti-ABA du réacteur) ;
  • timeout(future, dur) réutilise la sûreté d’annulation inc 1 : à l’échéance, il rend Elapsed et droppe la future enveloppée ⇒ son Drop appelle forget ⇒ l’op sous-jacente est annulée, buffer relâché au CQE.

Preuve : sleeps_are_precise_and_share_one_timer (10 sleep de 50 ms concurrents ⇒ ~50 ms, pas 500 ms : un seul timer kernel les sert), interval_ticks_periodically, et timeout_cancels_slow_op (un recv sur socket muet est annulé à l’échéance : Elapsed, op libérée au CQE, zéro fuite).

I2.3 — Back-pressure de soumission (§3)

Piège couche 0 : submit_receive/submit_read/… déplacent le buffer dans le slab avant de détecter le plein ; sur EBUSY (SQ/slab plein) le buffer serait perdu. On ne laisse donc jamais un arm_* porteur de buffer partir vers un EBUSY :

  • Reactor::ensure_capacity(waker) (couche 1) garantit une place avant que la future ne confie son buffer. Sinon il pompe (flush SQ + drain non bloquant des CQE prêts, ce qui libère des slots), et si c’est encore plein il enregistre le waker et rend false ;
  • la future d’I/O, sur false, reste Pending en conservant son buffer ;
  • le prochain turn (qui bloque pour ≥ 1 CQE) libère un slot puis réveille en bloc les futures différées, qui re-tentent. Le slab est construit avec max_inflight = entries (capacité connue) et le gate utilisateur réserve un slot pour la lecture d’eventfd (capacity − 1).

Jamais d’EBUSY remonté, jamais de perte d’op, jamais de panique. Preuve : submission_backpressure_no_loss — anneau de 8 entrées, 64 ops concurrentes (slab largement débordé) ⇒ toutes complètent (tracked == 0, kernel_in_flight == 0).

I2.4 — I/O fichier async (§4)

air-uring expose arm_read / arm_write (offset optionnel) / arm_fsync (rempart safe, aucun type io_uring fuité). air-async en tire un type [File] (read_at / write_at / fsync), cancel-safe et soumis à la back-pressure comme le réseau. Deuxième pilote I/O du motif sans-IO (ADR-091) après le réseau. Preuve : file_ops_read_write_fsync (couche 1) et file_async_roundtrip (couche 2) — écrit, fsync, relit, aller-retour exact (UTF-8 multi-octets inclus).

I2.5 — Layering, soundness & qualité

  • cargo xtask check-layers VERT : air-uring = 1, air-async = 2, aucun saut (seul advisory préexistant : air-libc-capi, cible-only ADR-087). air-uring reste #![no_std] + alloc (build non-test OK) ; zéro unsafe exposé (tout l’unsafe io_uring reste confiné en couche 0).
  • Sûreté d’annulation inc 1 conservée : les tests drop-en-vol restent verts ; kernel_in_flight() exclut désormais la lecture d’eventfd (métrique « ops utilisateur ») pour rester exacte.
  • valgrind (--leak-check=full --error-exitcode=99, --test-threads=1) sur les binaires de test d’air-uring et d’air-async : definitely lost: 0, ERROR SUMMARY: 0 errors, zéro invalid read/write. cargo fmt --check, cargo clippy -D warnings, cargo test : verts (7 tests air-uring, 12 tests air-async).

I2.6 — Ce qui reste (suites, pour l’incrément suivant)

  • Q4 (résiduel) — O(1) strict de la réconciliation turn (exposer le slot du SubmissionToken, couche 0 scellée → BDFL/RFC).
  • msg_ring & thread-per-core — bascule multi-ring (RingPool), éveil ring↔ring par IORING_OP_MSG_RING, vol de tâches.
  • Multishot accept/recv + provided buffers (débit) ; register_eventfd pour l’embarquement dans une boucle hôte externe (ADR-038 §5).
  • Micro-optim de la roue : timeout kernel persistant ré-armé seulement quand l’échéance la plus proche change (aujourd’hui : ré-arm par park, cancel-safe mais quelques ops d’annulation en trop sous interruption fréquente).
  • Anti-thundering-herd de la back-pressure : ne réveiller qu’autant de futures différées que de slots réellement libérés (aujourd’hui : réveil en bloc, borné).

INCRÉMENT 3 — thread-per-core + multishot + buffers fournis

Statut : implémenté et prouvé sur carbon (x86_64, host-first, ring io_uring réel sur la cible gnu). Branche feat/air-async-inc3. Monte le runtime en débit et parallélisme : plusieurs réacteurs (un par cœur), éveil inter-réacteur msg_ring, opérations multishot (accept/recv) et buffers fournis. La sûreté d’annulation des inc 1/2 est conservée (tests drop-en-vol + valgrind verts) et étendue au multishot (le point délicat). Répond aux suites Q5 (bascule thread-per-core), msg_ring, multishot + provided buffers et embarquement hôte.

I3.1 — Thread-per-core (ReactorPool, §1)

L’inc 2 avait un réacteur par thread mais mono-réacteur. L’inc 3 en fait tourner N (un par cœur), au-dessus du RingPool de la couche 0 (partage du pool io-wq par ATTACH_WQ — le nombre de threads workers kernel reste borné quel que soit N, crucial sur Pi 4 — et enregistrement des ring fds pour le routage msg_ring).

  • PooledRing — enveloppe Send, réacteur bâti sur son thread. Un Reactor range des Completion couche 0 dans ses slots, or Completion est !Send (elle porte des buffers via pointeurs bruts) : le Reactor est donc !Send. L’IoUring nu, lui, est Send (unsafe impl couche 0). On distribue donc l’anneau vide (ReactorPool::into_rings → Vec<PooledRing>, PooledRing enveloppant l’IoUring de façon opaque — aucun type io_uring ne fuit) et l’on bâtit le réacteur sur son propre thread (Reactor::from_pooled). La couche 1 reste ainsi sans le moindre unsafe (pur assemblage safe) : le seul unsafe impl Send vit en couche 0, sur l’anneau nu.
  • Piège cross-thread : REGISTER_RING_FDS est task-local. Le RingPool enregistre les ring fds sur le thread créateur ; un io_uring_enter(REGISTERED_RING) depuis un thread worker échouerait en EINVAL. from_pooled ré-enregistre le ring fd sur son thread (idempotent côté façade), retombant sur l’enter par FD ordinaire en cas d’échec. (Sans ce détail, turn deadlockerait/échouerait sur chaque worker.)

Éveil inter-réacteur — IORING_OP_MSG_RING (ring↔ring)

L’inc 2 réveillait un réacteur parké par eventfd (un thread quelconque écrit un fd). Le thread-per-core appelle un mécanisme ring-natif : un réacteur pousse le réveil vers un pair par son propre anneau, sans syscall séparé.

  • Reactor::wake_peer(peer) poste un IORING_OP_MSG_RING (MSG_DATA) vers le ring du pair puis flush. Le CQE injecté fait revenir le submit_and_wait parké du pair. Le user_data posé est une sentinelle hors-borne (slot = u32::MAX, bit 63 = 0) : côté pair, le slab couche 0 le filtre comme périmé (drainé, jeté) — seul l’éveil compte, pas le contenu. Le CQE de complétion de l’op msg_ring côté émetteur n’est pas suivi et est jeté au drainage (aucune fuite : son slot de slab se libère à la complétion).
  • RingPeer (Send + Copy) désigne un pair ; obtenue avant distribution (ReactorPool::peer).

Couche 2 — Cluster + spawn_on

Un Cluster de N Runtime (un par thread). Chaque worker possède une Inbox (Send + Sync, Mutex<Vec<future>>). spawn_on(from, k, fut) dépose fut (qui doit être Send) dans l’inbox de k, puis from.wake_peer(peer_k). La boucle d’exécution de k draine l’inbox en tête de chaque tour (et après chaque éveil) : la future reçue devient une tâche armée, pollée immédiatement.

Anti-perte de réveil. Le dépôt (verrou inbox) précède l’éveil msg_ring. Le worker cible draine l’inbox à chaque tour et est tiré de son park par le CQE msg_ring. Même si l’éveil arrive alors que k n’est pas encore parké, le CQE attend dans sa CQ : le prochain submit_and_wait le consomme sans blocage (le kernel vérifie la CQ avant de dormir). Le handshake SeqCst de l’inc 2 (annonce parked puis re-vérification) reste en vigueur pour la voie eventfd intra-thread.

Preuves. wake_peer_unparks_peer_reactor_via_msg_ring (couche 1 : B parké dans turn sur son thread, réveillé par le msg_ring de A) ; cluster_spawn_on_unblocks_peer_task_via_msg_ring (couche 2 : tâche postée+éveillée depuis A débloque la racine de B, résultat exact 42). La terminaison de ces tests EST la preuve — sans le msg_ring, ils deadlockeraient. (Le chemin est un vrai io_uring multi-ring : non modélisable en loom ; on suit la doctrine inc 2 — stress déterministe + terminaison = preuve.)

I3.2 — Multishot + buffers fournis (§2, le point délicat)

Modèle de slot

Le réacteur distingue désormais, dans chaque slot de suivi, une op mono-coup (chemin prouvé de l’inc 1, intact) d’un flux multishot (OpKind::{OneShot, AcceptStream, RecvStream}). Corrélation : une op mono-coup par son SubmissionToken (Completion::token), un flux par son MultishotToken (Completion::multishot_token) — la couche 0 scellée n’expose pas de conversion MultishotToken → SubmissionToken, mais fournit cancel_multishot et multishot_token, suffisants ici (pas de descellement requis).

  • accept multishot : une soumission → un flux de connexions (un CQE par connexion). Couche 2 : TcpListener::incoming()Incoming::accept().await.
  • recv multishot + buffers fournis : le kernel choisit un buffer d’un anneau fourni par op ; le CQE indique quel buffer. Couche 2 : Runtime::buffer_group(count, buf_size)TcpStream::recv_multishot(&group)RecvStream::recv().await.

Ownership du pool & extension de la sûreté d’annulation (le raisonnement)

L’anneau de buffers fournis est possédé par air-uring (couche 1). La contrainte d’io_uring — le buffer doit survivre à la complétion — se combine ici avec un modèle emprunt→copie→rendu :

  1. À chaque CQE de recv multishot, extract_provided obtient la garde RAII ProvidedBuffer (couche 0) qui emprunte le buffer au groupe, en copie les octets dans un Vec possédé, puis laisse tomber la garde — ce qui rend le buffer au pool immédiatement. Le rendu se fait donc exactement une fois par CQE, au drainage, quoi qu’il advienne du consommateur couche 2.
  2. Pourquoi la copie ? Un buffer fourni n’est pas un Vec séparément possédé : le pool est un unique bloc count × buf_size ring-mappé. On ne peut donc pas déplacer un buffer hors du pool ; on le copie puis on le rend. C’est le coût inhérent au modèle ring-mapped — assumé (le gain de débit du multishot — pas de SQE ni de gestion de buffer par recv — subsiste).
  3. Annulation sound. Annuler un recv multishot (forgetcancel_multishot, annulation synchrone par jeton) est sûr par construction :
    • les buffers des CQE déjà drainés ont déjà été copiés et rendus (étape 1) ;
    • les CQE encore en vol qui arrivent après l’annulation (orphelins) passent eux aussi par extract_provided : copie + rendu du buffer, puis les octets sont jetés (le flux est orphelin) ;
    • les fragments non lus en file sont des Vec possédés : simplement jetés, sans toucher au pool (leur buffer a déjà été rendu) ;
    • le CQE terminal (-ECANCELED, sans CQE_F_MORE) libère le slot couche 1. Conséquence : jamais de double-rendu, jamais de fuite, jamais d’UAF — le pool revient cohérent (available == count) après toute annulation.

Le forget unifie les deux natures : mono-coup → submit_cancel asynchrone ; flux → cancel_multishot synchrone ; un flux déjà terminal est libéré immédiatement (aucun CQE terminal ne viendrait sinon → fuite évitée).

Preuves. Couche 1 : accept_multishot_streams_connections_from_one_submission (3 connexions d’UNE soumission + annulation propre) ; recv_multishot_provided_buffers_roundtrip_and_clean_cancel (aller-retour exact + available == count avant/après annulation) ; recv_multishot_cancel_with_unread_fragments_is_sound (annulation avec fragments non lus : pool cohérent, zéro double-rendu/fuite). Couche 2 : incoming_accepts_many_from_one_submission, recv_multishot_stream_reassembles_and_pool_stays_coherent. valgrind (--test-threads=1, --leak-check=full) sur les binaires de test des deux crates : definitely lost: 0, ERROR SUMMARY: 0 errors, zéro invalid read/write — confirmant l’absence d’UAF et de fuite sur tous les chemins multishot, provided-buffer et thread-per-core.

I3.3 — Embarquement dans une boucle hôte (§3)

Reactor::enable_host_readiness() crée un eventfd et l’enregistre (IORING_REGISTER_EVENTFD) : le kernel l’écrit à chaque CQE posté. Reactor::readiness_fd() rend le fd à poller par un runtime hôte externe (epoll/GLib/tokio) : quand il devient lisible, le réacteur air-async a du travail prêt → l’hôte fait un turn non bloquant. L’hôte n’implémente pas io_uring (ADR-038 §5). Preuve : host_readiness_fd_becomes_readable_on_completion — un timeout armé + poussé en vol ; la lecture du fd de disponibilité ne revient qu’à la complétion (~50 ms) ⇒ le fd devient lisible quand une op complète.

I3.4 — Layering, soundness & qualité

  • cargo xtask check-layers VERT : air-uring = 1, air-async = 2, aucun saut (seul advisory préexistant : air-libc-capi, cible-only ADR-087). air-async ne nomme aucun type io_uring (RingPeer/PooledRing/BufferGroupId/StreamPoll sont des types couche 1 opaques ; le rempart cache toute la surface io_uring).
  • air-uring reste #![no_std] + alloc, zéro unsafe exposé (tout l’unsafe io_uring reste confiné en couche 0). cargo fmt --check, cargo clippy -D warnings, cargo test : verts (12 tests air-uring, 15 tests air-async).
  • Sûreté d’annulation inc 1/2 conservée : les tests drop-en-vol restent verts ; la métrique kernel_in_flight reste exacte (exclut l’eventfd d’éveil interne).

I3.5 — Ce qui reste (suites)

  • Q4 (résiduel) — O(1) strict de la réconciliation turn (exposer le slot du SubmissionToken, couche 0 scellée → BDFL/RFC).
  • Zéro-copie des buffers fournis — le modèle actuel copie hors du pool (contrainte ring-mapped, cf. I3.2). Une livraison zéro-copie exigerait de prêter la garde ProvidedBuffer jusqu’à la couche 2 (borrow traversant un await) ou un pool de Vec séparément possédés — à concevoir (mesure d’abord, Principe 5).
  • Vol de tâches (work-stealing) inter-réacteur au-delà du spawn_on dirigé actuel ; épinglage cœur explicite (sched_setaffinity) si exposé en couche 1.
  • Mode incrémental des buffers fournis (IOU_PBUF_RING_INC, un buffer sert plusieurs complétions) — non requis pour la preuve, gain mémoire ultérieur.
  • -ENOBUFS (pénurie de buffers) est aujourd’hui terminal côté flux (surfacé en Err) ; un réapprovisionnement + resoumission automatique est une suite.