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

Spec couche 0 — Module io_uring, Temps 1 : cœur de l’API

Spécification technique — Version 1.0 (cible kernel : Linux 6.12 LTS)

Position. Ce document spécifie le Temps 1 du module air-sys-syscall::io_uring : le cœur sur lequel reposent tous les autres Temps. Il dérive du document maître d’inventaire (io-uring-0-inventaire.md) et de l’ADR-022. Les décisions de soundness S1 (slab pré-alloué), S2 (téardown sûr) et S3 (sandbox) y sont rendues opérationnelles. Les opérations métier (submit_read, etc.) relèvent des Temps 2a–2d et 3a–3f ; ici on spécifie le ring, la soumission, la complétion, et leur sûreté.

Conventions couche 0 (ADR-021) appliquées sans exception : Result<_, Errno> partout, EINTR remonté à l’appelant (jamais de retry automatique), Option<T> au lieu des sentinelles kernel, FD typés (OwnedFd/BorrowedFd), pas d’allocation heap dans le happy path (S1), // SAFETY: sur chaque bloc unsafe.


1. Périmètre du Temps 1

Le Temps 1 couvre :

  1. Le modèle mémoire : les trois zones mmappées (SQ, CQ, SQE) et le protocole de publication par têtes/queues avec ordering acquire/release (§3). C’est la fondation de correction de tout le module.
  2. La construction du ring : IoUringBuilder, IoUring::new, io_uring_setup(2), négociation des features, application des setup flags (§5).
  3. Le slab d’opérations en vol (S1) : stockage sans allocation des buffers transférés et des métadonnées, encodage SubmissionToken slot+génération, back-pressure (§4).
  4. La soumission : submit, submit_and_wait, with(SubmitOptions), io_uring_enter(2) (§6).
  5. La complétion : wait_completion, wait_completion_timeout, try_completion, completions(), et le type Completion avec ses interprétations typées génériques (§7).
  6. Le téardown sûr (S2) : shutdown() explicite + Drop quiescent, bâtis sur sync_cancel (§8).
  7. L’introspection : supports_op (probe) et capabilities (§9).

Les trois syscalls sous-jacents (numéros identiques x86_64 / ARM64) :

Syscalln° x86_64n° ARM64Usage au Temps 1
io_uring_setup425425construction (§5)
io_uring_enter426426soumission + attente (§6, §7)
io_uring_register427427probe, sync_cancel, eventfd… (§8, §9)

2. Types fondamentaux introduits au Temps 1

TypeRôleair-sys-types ?
IoUringl’anneau (FD + mmaps + slab + capabilities). Send, !Sync.oui
IoUringBuilderconstruction configurée (flags, tailles, restrictions).oui
SetupFlagsbitflags des flags de io_uring_setup (axe A).oui
IoUringParamsmiroir typé de io_uring_params (entrées/sorties du setup).oui
IoUringCapabilitiesfeatures négociées (axe G), prédicats stables.oui
IoUringOpcodeénumération des 55 opcodes retenus (probe, restrictions).oui
SubmissionTokenjeton opaque slot+génération (S1).oui
SubmitOptionsoptions par-op (drain, async, skip-cqe, link — exposé Temps 3c).oui
Completionune complétion typée (CQE décodé).oui
CompletionFlagsbitflags des flags de CQE (axe E).oui
CompletionIter<'ring>itérateur sur les complétions disponibles.non (emprunt)
CancelTargetcible d’annulation (token / fd / op / any).oui

3. Modèle mémoire et protocole d’anneau

Le Temps 1 implémente ce protocole mais ne l’expose pas crûment (le brut est au Temps 4, ::raw). Cette section fixe le contrat de correction.

3.1 Les trois zones mmappées

io_uring_setup retourne un FD et remplit io_uring_params avec deux jeux d’offsets (io_sqring_offsets, io_cqring_offsets). On mmappe :

  • Anneau SQ à l’offset IORING_OFF_SQ_RING : contient head, tail, ring_mask, ring_entries, flags, dropped, et (sauf NO_SQARRAY) le tableau d’index array.
  • Tableau SQE à l’offset IORING_OFF_SQES : ring_entries entrées de 64 o (ou 128 o si SQE128).
  • Anneau CQ à l’offset IORING_OFF_CQ_RING : head, tail, ring_mask, ring_entries, overflow, flags, et le tableau cqes (16 o, ou 32 o si CQE32).

Avec IORING_FEAT_SINGLE_MMAP (toujours présent en 6.12), SQ et CQ partagent une seule mmap ; on le détecte et on adapte (deux mmaps sinon). Avec NO_MMAP, l’appelant fournit la mémoire — non couvert au Temps 1 (option avancée, gated).

3.2 Protocole de publication — ordering mémoire

Les têtes/queues sont des compteurs 32 bits monotones partagés avec le kernel ; l’index réel = compteur & ring_mask. L’ordering est non négociable :

Soumission (userspace producteur de la SQ) :

  1. Écrire le SQE complet dans sqes[tail & mask].
  2. (Sauf NO_SQARRAY) écrire tail & mask dans array[tail & mask].
  3. Publier la nouvelle tail par un store release (AtomicU32::store(.., Release)) — garantit que le kernel voit les écritures du SQE avant de voir la queue avancer.
  4. head de la SQ est lue par un load acquire pour calculer la place disponible.

Complétion (userspace consommateur de la CQ) :

  1. Lire tail de la CQ par un load acquire — garantit que les CQE écrits par le kernel sont visibles.
  2. Lire les CQE entre head et tail.
  3. Publier la nouvelle head par un store release pour rendre les entrées au kernel.

Toute implémentation doit respecter ce protocole ; il est testé par modèle (loom, §11) en plus des tests fonctionnels. Référence : io_uring(7) et io_uring_setup(2).

3.3 Réveil et drapeaux d’anneau

  • IORING_SQ_NEED_WAKEUP (flags SQ) : en mode SQPOLL, si positionné, il faut réveiller le thread kernel via io_uring_enter(.., SQ_WAKEUP) (géré au Temps 3e ; au Temps 1 on lit/expose le drapeau).
  • IORING_SQ_CQ_OVERFLOW : la CQ a débordé ; avec IORING_FEAT_NODROP (présent en 6.12) le kernel retient les complétions et les re-livre, mais signale l’état. Le Temps 1 le surface (IoUring::completion_queue_overflowed()).
  • IORING_SQ_TASKRUN : du task-work est en attente (avec TASKRUN_FLAG) ; indique qu’une entrée kernel est utile.

4. Le slab d’opérations en vol (décision S1)

4.1 Problème et forme

Entre une soumission et sa complétion, le kernel détient potentiellement un buffer (modèle de transfert d’ownership, ADR-022 Décision 3) et la façade doit retrouver, à la complétion, quelle opération et quel buffer correspondent au CQE. Le lien est user_data (u64, posé dans le SQE, rendu dans le CQE).

Une table dynamique allouerait par opération → interdit (CLAUDE.md). On utilise un slab pré-alloué :

struct InflightSlab {
    slots: Box<[Slot]>,   // alloué UNE fois à la construction
    free_head: Option<u32>,
    in_flight: u32,
}

struct Slot {
    generation: u32,           // incrémenté à chaque réutilisation (compteur de génération anti-réutilisation)
    state: SlotState,          // Free | Inflight { kind, buffer } | Multishot { .. }
}
  • Capacité = nombre maximal d’opérations simultanément en vol, par défaut cq_entries (le kernel ne peut pas avoir plus de complétions pendantes que la CQ, modulo overflow géré par NODROP). Configurable via le builder.
  • Le buffer transféré est déplacé (move) dans le slot : aucune copie, aucune réallocation dans le happy path.

4.2 Encodage du SubmissionToken

SubmissionToken encapsule { slot: u32, generation: u32 }. Le user_data kernel = ((generation as u64) << 32) | (slot as u64). À la complétion :

  1. décoder user_data(generation, slot) ;
  2. si slots[slot].generation != generation → complétion périmée (op déjà annulée/recyclée, p. ex. CQE multishot tardif) : on l’ignore proprement ;
  3. sinon, on consomme le slot et on restitue le buffer à l’appelant.

4.3 Back-pressure

submit_* réserve un slot avant d’écrire le SQE. Slab plein (in_flight == capacity) ⇒ retour Err(Errno::EBUSY) sans toucher au kernel. C’est la back-pressure structurelle d’Air : on refuse poliment plutôt que de déborder.

4.4 Multishot

Une opération multishot (Temps 3d) occupe un slot mais produit plusieurs CQE. Le slot reste vivant tant que les complétions portent IORING_CQE_F_MORE ; il est libéré à la complétion finale (sans F_MORE) ou à l’annulation. La génération protège contre les CQE arrivant après annulation.


5. Construction du ring

5.1 IoUringBuilder

#![allow(unused)]
fn main() {
pub struct IoUringBuilder { /* ... */ }

impl IoUringBuilder {
    pub fn new(entries: NonZeroU32) -> Self;
    pub fn with_completion_queue_entries(self, entries: NonZeroU32) -> Self;       // SETUP_CQSIZE
    pub fn max_inflight(self, n: NonZeroU32) -> Self;            // capacité du slab
    pub fn with_flags(self, flags: SetupFlags) -> Self;
    pub fn with_sqpoll_idle(self, dur: Duration) -> Self;            // SQPOLL (Temps 3e)
    pub fn with_sqpoll_cpu(self, cpu: u32) -> Self;                  // SQ_AFF
    pub fn attach_work_queue(self, other: &IoUring) -> Self;            // ATTACH_WQ
    pub fn restrict(self, restrictions: &[Restriction]) -> Self; // S3 (Temps 3f)
    pub fn build(self) -> Result<IoUring, Errno>;
}
}

build — comportement.

  • Traduit la configuration en io_uring_params, appelle io_uring_setup(2).
  • entries est arrondi par le kernel à la puissance de 2 supérieure ; on lit les tailles effectives en retour (sq_entries, cq_entries).
  • mmappe SQ/CQ/SQE selon §3.1 (RAII : les mmaps sont possédées par IoUring).
  • alloue le slab (§4) une fois, dimensionné à max_inflight ou cq_entries.
  • lit params.featuresIoUringCapabilities.
  • si REGISTERED_FD_ONLY/REG_REG_RING : enregistre le ring fd et bascule en mode fd enregistré de façon transparente.
  • si restrict(..) non vide : pose R_DISABLED, applique REGISTER_RESTRICTIONS, laisse le ring désactivé (l’appelant appelle enable() ; cf. Temps 3f).

Préconditions. entries ≤ limite kernel (sinon EINVAL). Combinaisons de flags incompatibles refusées (p. ex. IOPOLL + SQPOLL selon contexte) : on remonte l’EINVAL du kernel sans le masquer.

Erreurs. EINVAL (params invalides), ENOMEM (mémoire), EPERM (SQPOLL/affinité sans privilège selon config), EFAULT, ENOSYS (io_uring indisponible — sandbox/container : cf. §10).

Performance. Coût dominé par io_uring_setup + mmaps + alloc unique du slab : ~quelques dizaines de µs. Fait une fois, hors chemin chaud.

Exemple.

#![allow(unused)]
fn main() {
use core::num::NonZeroU32;
let ring = IoUringBuilder::new(NonZeroU32::new(256).unwrap())
    .with_flags(SetupFlags::SINGLE_ISSUER | SetupFlags::DEFER_TASKRUN)
    .build()?;
}

Tests. unit (tailles arrondies, lecture features), property (entries arbitraires → invariants tailles), intégration (création réelle sur 6.12), simulateur de syscall pour forcer ENOSYS/ENOMEM, fuzzing des combinaisons de flags.

5.2 IoUring::new et enable

#![allow(unused)]
fn main() {
impl IoUring {
    pub fn new(entries: NonZeroU32) -> Result<Self, Errno>; // = builder.build()
    pub fn enable(&mut self) -> Result<(), Errno>;          // REGISTER_ENABLE_RINGS
    pub fn completion_queue_overflowed(&self) -> bool;                    // lit SQ_CQ_OVERFLOW
    pub fn submission_queue_space_left(&self) -> u32;
    pub fn in_flight(&self) -> u32;                          // slots occupés (S1)
}
}

enable n’est utile que pour un ring créé désactivé (via restrict) ; sur un ring déjà actif il retourne Err(EINVAL) (remonté tel quel).


6. Soumission

6.1 submit et submit_and_wait

#![allow(unused)]
fn main() {
impl IoUring {
    pub fn submit(&mut self) -> Result<u32, Errno>;
    pub fn submit_and_wait(&mut self, want: u32) -> Result<u32, Errno>;
}
}

Comportement. submit publie la queue SQ (§3.2) puis appelle io_uring_enter(fd, to_submit, 0, 0, ..) ; retourne le nombre de SQE consommés par le kernel. submit_and_wait(want) ajoute IORING_ENTER_GETEVENTS et min_complete = want.

En mode SQPOLL, si le thread kernel tourne, submit peut n’avoir aucun syscall à faire (juste la publication release) ; il ne réveille le thread que si SQ_NEED_WAKEUP.

EINTR. Remonté tel quel (ADR-021 conv. 2). L’appelant qui veut réessayer écrit sa boucle.

Erreurs. EINTR, EAGAIN (ressources momentanées), EBUSY (CQ pleine non drainée selon contexte), EINVAL, EFAULT, EBADF.

Performance. Le gain d’io_uring est ici : on batch N opérations pour un seul io_uring_enter. Un submit sans attente sur ring SQPOLL chaud ≈ coût d’un store atomique.

6.2 Options par-opération

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, Default)]
pub struct SubmitOptions { /* ... */ }
impl SubmitOptions {
    pub fn drain(self) -> Self;               // IOSQE_IO_DRAIN
    pub fn force_async(self) -> Self;         // IOSQE_ASYNC
    pub fn skip_cqe_on_success(self) -> Self; // IOSQE_CQE_SKIP_SUCCESS (FEAT_CQE_SKIP)
    // link / hardlink exposés via LinkedChainBuilder (Temps 3c)
}

impl IoUring {
    pub fn with(&mut self, opts: SubmitOptions) -> &mut Self;
}
}

with applique les options à la prochaine soumission submit_*. Note : skip_cqe_on_success libère le slot S1 à la soumission (pas de CQE attendu en cas de succès) — la spec du slot documente ce cas particulier de libération.


7. Complétion

7.1 Récupération

#![allow(unused)]
fn main() {
impl IoUring {
    pub fn wait_completion(&mut self) -> Result<Completion, Errno>;
    pub fn wait_completion_timeout(&mut self, timeout: Duration)
        -> Result<Option<Completion>, Errno>;
    pub fn try_completion(&mut self) -> Option<Completion>;
    pub fn completions(&mut self) -> CompletionIter<'_>;
}
}
  • wait_completion : bloque jusqu’à au moins une complétion (io_uring_enter(.., GETEVENTS, min_complete=1)), la décode et la consomme.
  • wait_completion_timeout : utilise IORING_ENTER_EXT_ARG (io_uring_getevents_arg + __kernel_timespec) ou ABS_TIMER (FEAT_MIN_TIMEOUT) ; Ok(None) à l’expiration.
  • try_completion : non bloquant, lit la CQ sans syscall si des CQE sont présents (load acquire de la queue).
  • completions() : draine ce qui est disponible, avance head (store release) à la fin de l’itération ou par lot.

Complétion périmée. Si le user_data décodé pointe un slot dont la génération ne correspond pas (§4.2), la complétion est filtrée : ces fonctions passent à la suivante sans la rendre à l’appelant.

7.2 Le type Completion

#![allow(unused)]
fn main() {
pub struct Completion { /* token, res, flags, accès au slot pour reprise buffer */ }

impl Completion {
    pub fn token(&self) -> SubmissionToken;
    pub fn raw_result(&self) -> i32;          // brut, sémantique selon l'op
    pub fn flags(&self) -> CompletionFlags;

    pub fn has_more(&self) -> bool;           // CQE_F_MORE (multishot)
    pub fn is_notif(&self) -> bool;           // CQE_F_NOTIF (zero-copy)
    pub fn buffer_id(&self) -> Option<u16>;   // CQE_F_BUFFER
    pub fn socket_has_pending_data(&self) -> bool;      // CQE_F_SOCK_NONEMPTY

    // Interprétation générique (les variantes typées riches sont aux Temps 2x) :
    pub fn into_result(self) -> Result<i32, Errno>;       // res<0 => Err(-res)
    pub fn into_buffer_result(self) -> Result<(Vec<u8>, usize), Errno>; // reprise buffer S1
    pub fn completed(&self) -> Result<(), Errno>;         // succès sans valeur
}
}

Convention de résultat. Un res négatif est un -errno ; les méthodes into_* le convertissent en Err(Errno). Un res ≥ 0 est la valeur utile (octets, fd, etc.), interprétée par la méthode appropriée — le développeur sait quelle opération il a soumise (ADR-022 Décision 9).

Reprise du buffer. into_buffer_result récupère le buffer déplacé hors du slot (S1) et le rend à l’appelant avec le nombre d’octets — zéro copie.

7.3 CompletionFlags

bitflags des 5 flags de l’axe E : BUFFER, MORE, SOCK_NONEMPTY, NOTIF, BUF_MORE.


8. Téardown sûr (décision S2)

8.1 sync_cancel (brique)

#![allow(unused)]
fn main() {
pub enum CancelTarget {
    Token(SubmissionToken),
    Fd(BorrowedFd<'_>),
    Op(IoUringOpcode),
    Any,
}
impl IoUring {
    pub fn sync_cancel(&mut self, target: CancelTarget) -> Result<u32, Errno>;
}
}

S’appuie sur IORING_REGISTER_SYNC_CANCEL (io_uring_sync_cancel_reg, avec timeout). Mappe CancelTarget sur les flags IORING_ASYNC_CANCEL_* (USERDATA / FD / OP / ANY). Retourne le nombre d’opérations annulées.

8.2 shutdown et Drop

#![allow(unused)]
fn main() {
impl IoUring {
    pub fn shutdown(self) -> Result<(), Errno>;
}
impl Drop for IoUring { fn drop(&mut self); }
}

shutdown (voie propre).

  1. sync_cancel(Any) avec timeout borné.
  2. Drainer la CQ jusqu’à in_flight() == 0 (les buffers des slots sont libérés proprement à mesure).
  3. munmap des anneaux, fermeture du FD (RAII), libération du slab.
  4. Retourne Err si le drainage échoue/expire (l’appelant décide).

Drop (filet de sécurité). Si in_flight() > 0, exécute la même séquence de quiescence mais de façon bloquante et best-effort (boucle bornée). Le coût (blocage potentiel) est documenté : sur chemin chaud, préférer shutdown(). Justification : « sur-sécuriser puis dégraisser » (Principe 5) — un Drop qui laisse le kernel écrire dans de la mémoire libérée est un défaut de soundness inacceptable en couche 0.

Invariant de sûreté. Tant qu’une opération est en vol, le buffer associé vit dans son slot (S1) et n’est pas libérable par l’appelant ; le ring ne peut pas être détruit sans quiescence. Ces deux invariants garantissent qu’aucune écriture kernel ne tombe sur de la mémoire libérée.


9. Introspection : probe et capabilities

#![allow(unused)]
fn main() {
impl IoUring {
    pub fn supports_op(&self, op: IoUringOpcode) -> bool;
    pub fn capabilities(&self) -> IoUringCapabilities;
}
}
  • supports_op : interroge IORING_REGISTER_PROBE (mis en cache à la construction). Permet le fallback vers les syscalls synchrones quand une op n’est pas supportée par le kernel courant (ADR-022 Décision 8).
  • capabilities : expose les 16 features de l’axe G via des prédicats stables (single_mmap, nodrop, cqe_skip, min_timeout, recvsend_bundle, reg_reg_ring, …).

Exemple de fallback.

#![allow(unused)]
fn main() {
if ring.supports_op(IoUringOpcode::Openat2) {
    let tok = ring.submit_openat2(/* ... */)?; // Temps 2a
} else {
    // bascule sur air-sys-syscall::fs::openat2 synchrone
}
}

10. Indisponibilité d’io_uring (ADR-022 Décision 10)

Si le kernel ne supporte pas io_uring ou si l’environnement l’a désactivé (seccomp, sandbox, container durci), build() retourne Err(Errno::ENOSYS) (ou EPERM selon le filtre). Aucun fallback automatique caché : l’appelant choisit de basculer en synchrone ou de refuser de démarrer.


11. Stratégie de tests (couche 0 — 100 % lignes + branches)

  • Unitaires : encodage/décodage SubmissionToken (slot+génération), arithmétique des têtes/queues (masquage, wrap), back-pressure EBUSY, filtrage des complétions périmées.
  • Property-based (proptest) : pour toute séquence de submit/complete, les invariants in_flight ≤ capacity, head ≤ tail, aucun slot doublement libéré, aucun buffer perdu.
  • Modèle de concurrence (loom) : le protocole acquire/release de §3.2 sur un modèle producteur(userspace)/consommateur(kernel simulé) ; détecte tout ordering manquant.
  • Intégration : sur kernel 6.12 réel — création, nop, submit/wait, timeout, shutdown propre, Drop avec ops en vol, overflow CQ (NODROP).
  • Simulateur de syscalls : harnais injectant EINTR, EAGAIN, ENOSYS, EFAULT aux frontières enter/setup/register pour couvrir les branches d’erreur sans dépendre du kernel.
  • Fuzzing (cargo-fuzz) : décodage des CQE et des io_uring_params retournés (toute donnée venant du kernel est traitée comme externe, Principe 3).
  • Tests de Drop : Miri/valgrind pour confirmer l’absence d’usage après libération sur les chemins de quiescence.

Branches non couvrables (p. ex. erreurs kernel impossibles à provoquer) : consignées dans docs/COVERAGE-EXCEPTIONS.md avec justification.


12. Récapitulatif des erreurs (Temps 1)

FonctionErreurs notables
build / newEINVAL, ENOMEM, EPERM, ENOSYS, EFAULT
enableEINVAL, EBADF
submit / submit_and_waitEINTR, EAGAIN, EBUSY, EINVAL, EBADF, EFAULT
wait_completion*EINTR, ETIME (timeout interne, selon mapping), EBADF
submit_* (réservation slot)EBUSY (slab plein) avant tout syscall
sync_cancelEINTR, EALREADY, ENOENT, EINVAL
shutdownpropage les erreurs de drainage/cancel

13. Types ajoutés à air-sys-types (Temps 1)

IoUring, IoUringBuilder, IoUringParams, SetupFlags, IoUringCapabilities, IoUringOpcode, SubmissionToken, SubmitOptions, Completion, CompletionFlags, CancelTarget. Soit ~11 types publics (le slab et les wrappers de mmap sont internes, non exposés).


14. Décisions de fond émergées au Temps 1

  1. SubmissionToken = slot+génération, pas user_data brut. L’API ne laisse jamais l’appelant manipuler un user_data arbitraire : la génération protège contre l’ABA et les complétions périmées (multishot/cancel).
  2. Slab dimensionné par défaut à cq_entries. Aligne la back-pressure applicative sur la capacité réelle de complétions du kernel.
  3. Drop bloquant assumé. Choix de soundness sur performance, réversible après mesure (mais jamais dans le sens dangereux).
  4. Pas de trait Submittable au Temps 1. Méthodes inhérentes ; on n’introduit l’abstraction que si les Temps 3c/3d en prouvent le besoin (Principe 7).
  5. Timeouts via EXT_ARG/ABS_TIMER, pas via une opération TIMEOUT liée — celle-ci reste disponible au Temps 2c pour les cas explicites.

15. Travail à reprendre

Specs suivantes, sur ce modèle : io-uring-2a-filesystem.md (les 26 opérations FS s’appuyant sur le cœur ci-dessus), puis 2b, 2c, 2d, 3a–3f, 4. La traduction anglaise globale est produite une fois les documents français validés.


Licence du document : MPL 2.0 Statut : Spécification technique du Temps 1 (cœur) du module air-sys-syscall::io_uring, cible kernel 6.12 LTS.