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
-
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’unsafeio_uring de la couche 0, exactement commeair-pollexposeppoll) ;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-layersVERT (§4). -
Modèle d’exécution (aligné ADR-038) : mono-thread d’abord (un réacteur par thread — l’
IoUringcouche 0 estSend + !Sync), buffers possédés (déjà garanti par la couche 0 — rien à réinventer), un CQE → un réveil deWaker, timers =IORING_OP_TIMEOUT(io_uring seul, pas detimerfd). -
Squelette prouvé (§3) :
air-uring::Reactor+air-async::block_on+ futureTimeout; 3 tests verts exécutant de vraies opérationsIORING_OP_TIMEOUTsur l’hôte gnu.check-layers/clippy -D/fmtpropres. -
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
SubmissionToken → Waker.
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-asyncen 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éveilWaker) 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-layerspasse 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-runtimeest lecrt0/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’appellentair-async. Divergence de nommage à réconcilier par l’ADR (§6, Q1). On adopteair-async(mission + ADR-091, plus récent). La substance d’ADR-038 (runtime en couche 2) est respectée : l’exécuteurair-asyncest en couche 2 ; seul le réacteur descend en couche 1, ce qu’ADR-038 n’interdit pas (il ne dit pas où 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.
IoUringestSend + !Sync⇒ un réacteur par thread. Le squelette est mono-thread ; le thread-per-core (viaRingPool/LockedIoUringcouche 0 + vol de tâches) est un incrément ultérieur. Le réacteur est partagé avec les futures parRc<RefCell<Reactor>>(ReactorHandle) — les emprunts ne se chevauchent jamais dans le temps (lepollrend la main avant chaqueturn). - 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 detokio-uring, gratuit ici. (Le squelette exerceIORING_OP_TIMEOUT, sans buffer ; la futureRead/Recvsuivra le même patron avecinto_buffer_result.) - Un CQE → un réveil.
ReactortientSubmissionToken → (Waker, Option<res>).Reactor::turn():submit_and_wait(1)(soumet la file + bloque), puis draine la CQ non bloquant (try_completion), range chaqueressous son jeton et réveille leWakerenregistré.block_onre-poll sur réveil. - Timers =
IORING_OP_TIMEOUT. ADR-038 §3 : io_uring seul moteur (readiness et timeouts), pas detimerfd, pas d’epoll. La futureTimeoutarme unsubmit_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_removeexistent ; 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) vsair-async(ADR-091/mission). Trancher et amender l’ADR. Et : le réacteur couche 1 s’appelle-t-ilair-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_cancelau 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
Veclinéairement. La production doit indexer par le slot du jeton (O(1)). Faut-il qu’air-uringexpose 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
Wakerest déclenché depuis un autre thread (channel) alors que le réacteur est bloqué danssubmit_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 seamas_fd()d’air-socketpour 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-ellePendingjusqu’à 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_completionle rend dans uneCompletion). 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 (recvsur socket muet,acceptsans 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 :- la future d’I/O, dans son
Drop(si son op est encore en vol), appelleReactor::forget(token)— elle ne libère rien ; forgetmarque le slot de suivi orphelin (plus deWaker) et soumet unIORING_OP_ASYNC_CANCELciblant le jeton, puis laisse l’op en vol ;- au CQE correspondant (complétion nominale ou
-ECANCELED),Reactor::turnréconcilie l’orphelin : il libère le slot et laisse tomber laCompletion— 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 laCompletiondroppée : l’op ayant déjà quitté le kernel, relâcher le buffer est sûr, sans annulation. - la future d’I/O, dans son
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
recvsur 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 unDropde future) ; on prouve que l’orphelin reste suivi (buffer non relâché), qu’une annulation est soumise, puis qu’auturnl’orphelin est réconcilié et le buffer relâché exactement au CQE (tracked == 0,kernel_in_flight == 0).
- Test drop-en-vol (
- Stress déterministe (
cancellation_stress_no_leak) : 200 cycles arm-recv-en-vol →forget→turn; 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) : unacceptmis 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-uringet d’air-asyncpassent sousvalgrind --leak-check=full --error-exitcode=99—ERROR 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âchesspawnées dont le drapeau de réveil est levé, puis park sur le réacteur (turn=submit_and_wait+ drain) quand tout estPending. Un CQE réveille leWakerde la tâche concernée (⇒ drapeau levé) ; le tour suivant la re-polle.block_on(fonction libre) reste la variante mono-future.Wakerpar tâche :Arc<TaskWaker>(drapeau atomique).Send + Sync(exigé parstd::task::Wake) bien qu’on reste mono-thread ; le CQE d’une op réveille toujours sa tâche d’origine, jamais une autre. LesDropde futures s’exécutent hors de tout emprunt du réacteur (la tâche est sortie de la table avant son poll), doncforgetauDropn’entre jamais en collisionRefCell.JoinHandle<T>:await-able ; la glue despawncapture la sortie et réveille le joignant.- Corrélation token→tâche (Q4) : récupération / oubli / re-
Wakeren O(1) par indexation directe du slot (l’OpTokenporte l’index). La réconciliation CQE→slot dansturnreste un balayage borné (par la concurrence de pointe) : un O(1) strict à ce point exigerait d’exposer le slot duSubmissionTokendepuis la couche 0 scellée — dette explicitement assumée (voir « Suites »), jamais contournée par un raccourci. - Timers :
IORING_OP_TIMEOUTper-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-layersVERT :air-uring= 1,air-async= 2, aucun saut (le seul advisory est préexistant,air-libc-capi, cible-only ADR-087).air-asyncne nomme aucun type io_uring (vérifié : seules des mentions en commentaire/opcode subsistent).cargo fmt --check,cargo clippy -D warnings(dontarithmetic_side_effects,cast_possible_truncation),cargo test -p air-uring -p air-async: verts.air-uringreste#![no_std]+alloc(build non-test OK) ; zérounsafeexposé (tout l’unsafeio_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 duSubmissionToken(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 enPending/ré-essai. - Roue de timers ; multishot
accept/recv(Temps 3d couche 0) ;read/writefichiers 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
Reactorcrée uneventfd(compteur,CLOEXEC|NONBLOCK) et tient dessus une lecture permanente (IORING_OP_READde 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_waitrevient ; Reactor::wake_handle()rend unWakeHandleSend + Syncclonable (face « écriture », unArc<EventFd>).WakeHandle::wake()écrit1— 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 viaforget, 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 rendElapsedet droppe la future enveloppée ⇒ sonDropappelleforget⇒ 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 rendfalse;- la future d’I/O, sur
false, restePendingen 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 avecmax_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-layersVERT :air-uring= 1,air-async= 2, aucun saut (seul advisory préexistant :air-libc-capi, cible-only ADR-087).air-uringreste#![no_std]+alloc(build non-test OK) ; zérounsafeexposé (tout l’unsafeio_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-uringet 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 testsair-uring, 12 testsair-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 duSubmissionToken, couche 0 scellée → BDFL/RFC). msg_ring& thread-per-core — bascule multi-ring (RingPool), éveil ring↔ring parIORING_OP_MSG_RING, vol de tâches.- Multishot
accept/recv+ provided buffers (débit) ;register_eventfdpour 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— enveloppeSend, réacteur bâti sur son thread. UnReactorrange desCompletioncouche 0 dans ses slots, orCompletionest!Send(elle porte des buffers via pointeurs bruts) : leReactorest donc!Send. L’IoUringnu, lui, estSend(unsafe implcouche 0). On distribue donc l’anneau vide (ReactorPool::into_rings → Vec<PooledRing>,PooledRingenveloppant l’IoUringde 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 moindreunsafe(pur assemblage safe) : le seulunsafe impl Sendvit en couche 0, sur l’anneau nu.- Piège cross-thread :
REGISTER_RING_FDSest task-local. LeRingPoolenregistre les ring fds sur le thread créateur ; unio_uring_enter(REGISTERED_RING)depuis un thread worker échouerait enEINVAL.from_pooledré-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,turndeadlockerait/é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 unIORING_OP_MSG_RING(MSG_DATA) vers le ring du pair puis flush. Le CQE injecté fait revenir lesubmit_and_waitparké du pair. Leuser_dataposé 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’opmsg_ringcô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).
acceptmultishot : une soumission → un flux de connexions (un CQE par connexion). Couche 2 :TcpListener::incoming()→Incoming::accept().await.recvmultishot + 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 :
- À chaque CQE de
recvmultishot,extract_providedobtient la garde RAIIProvidedBuffer(couche 0) qui emprunte le buffer au groupe, en copie les octets dans unVecpossé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. - Pourquoi la copie ? Un buffer fourni n’est pas un
Vecséparément possédé : le pool est un unique bloccount × buf_sizering-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). - Annulation sound. Annuler un
recvmultishot (forget→cancel_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
Vecpossédés : simplement jetés, sans toucher au pool (leur buffer a déjà été rendu) ; - le CQE terminal (
-ECANCELED, sansCQE_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-layersVERT :air-uring= 1,air-async= 2, aucun saut (seul advisory préexistant :air-libc-capi, cible-only ADR-087).air-asyncne nomme aucun type io_uring (RingPeer/PooledRing/BufferGroupId/StreamPollsont des types couche 1 opaques ; le rempart cache toute la surface io_uring).air-uringreste#![no_std]+alloc, zérounsafeexposé (tout l’unsafeio_uring reste confiné en couche 0).cargo fmt --check,cargo clippy -D warnings,cargo test: verts (12 testsair-uring, 15 testsair-async).- Sûreté d’annulation inc 1/2 conservée : les tests drop-en-vol restent verts ;
la métrique
kernel_in_flightreste 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 duSubmissionToken, 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
ProvidedBufferjusqu’à la couche 2 (borrow traversant unawait) ou un pool deVecséparément possédés — à concevoir (mesure d’abord, Principe 5). - Vol de tâches (work-stealing) inter-réacteur au-delà du
spawn_ondirigé 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é enErr) ; un réapprovisionnement + resoumission automatique est une suite.