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

air-poll — multiplexeur d’événements générique

Couche : 1. Statut : implémentée (brique mince). Périmètre : roadmap couche 1 (libc + PAL) §5.3 P0.2 ; audit face-libc famille #14 « poll/select » (§2.1) et §5 P0-2. Companion couche 0 : family-poll.md (ppoll, ADR-044, re-sceau couche-0-v1.5). Conventions : ADR-021 (EINTR remonté, conv.2). Erreurs : ADR-019. no_std : ADR-048 / ADR-055. Nommage : ADR-029 (zéro abréviation).

1. Raison d’être

Toute event loop synchrone — la boucle principale d’OpenSSH au premier chef — doit attendre qu’un descripteur parmi un ensemble arbitraire devienne prêt : sockets, pipes, fichiers, pidfd, signalfd, timerfd, eventfd… tous convergent vers poll. La couche 0 fournit ppoll(2) (air_sys_syscall::poll::ppoll, ADR-044), mais la couche 1 n’en offrait aucun point d’entrée générique : air-process::AirProcess::wait_until l’affleurait, couplé au pidfd de l’enfant possédé (§14 de l’audit face-libc). air-poll comble ce manque additif fondateur (§5 P0-2) : le tier fidèle qui expose ppoll sur un &mut [PollFd] quelconque, typé couche 1.

Décision de placement — crate dédiée

Le multiplexage d’événements n’est ni process- ni socket-spécifique : le greffer sur air-process (déjà porteur de wait_until) ou air-socket reproduirait précisément le couplage que l’audit reproche à wait_until. Conformément à la doctrine des crates couche 1 « minces et bornées » (air-env, air-stdio, air-terminal : une responsabilité, zéro dépendance externe), le multiplexeur mérite sa crate dédiée. air-poll s’insère au même niveau que ses sœurs : au-dessus de la couche 0 (air-sys-syscall/air-sys-types) et du cœur d’erreurs/temps (air-base-core, ADR-054), sans dépendre d’aucune autre crate couche 1 — donc aucun cycle, aucune dette d’agencement (esprit ADR-055).

2. Périmètre — un appel = un ppoll

Cette crate fournit exactement :

ÉlémentContenu
Multiplexeur[poll](&mut [PollFd], Option<AirDuration>, Option<&SignalMask>) -> AirResult<usize> — écrit les revents en place, rend le nombre de fd prêts
Dépouillement[ready](&[PollFd]) -> impl Iterator<Item = &PollFd> — itère les entrées prêtes sans indexation paniquante
Types ré-exportésPollFd / PollEvents / SignalMask (depuis la couche 0, inchangés sur le fil)

Aucune intelligence propre : ni boucle de retry, ni bufferisation, ni récolte waitid (ça, c’est le rôle spécialisé de air-process::wait_until).

HORS PÉRIMÈTRE

  • select(2) — modèle legacy (fd_set borné à FD_SETSIZE, coût O(max_fd), ré-armement destructif des ensembles). Inférieur à poll sur tous les axes. Une façade select/pselect compatible libc s’exprimera au-dessus de poll, en face-C, si un userland l’exige — Air ne rétrograde pas sa surface native vers fd_set (doctrine « variantes modernes », kernel = bible, ADR-021).
  • epoll — surface stateful (epoll_create/_ctl/_wait) relevant du runtime asynchrone io_uring (couche 1 air-runtime, ADR-038 : pas d’epoll), pas de ce multiplexeur synchrone one-shot.
  • La boucle de reprise EINTR et le recalcul d’échéance : c’est l’event loop appelante qui les écrit (helpers de retry au-dessus, ADR-021 conv.2).

3. Sémantique fidèle

  • revents en place, retour = nombre de prêts — fidèle à poll(2) : chaque [PollFd] porte au retour les événements du kernel (vide si non prêt) ; l’entier rendu est le nombre d’entrées à revents non vide (0 = expiration). Restitution intégrale des bits du kernel (ADR-032, from_bits_retain).

  • Timeout typé [Option<AirDuration>] (jamais d’entier magique, ADR-021) :

    ValeurEffet
    NoneAttente indéfinie (timespec NULL) — seul chemin non borné
    Some(AirDuration::ZERO)Sondage non bloquant (revient aussitôt)
    Some(d), d > 0Attente bornée à d
    Some(d), d ≤ 0Dégénère en sondage (ZERO) — jamais infini

    La conversion vers le timespec couche 0 passe par AirDuration::to_std_saturating : bornage défensif (durée ≤ 0 → ZERO, secondes clampées à u64::MAX), aucun as lossy (Principe 2). Le piège classique « timeout négatif ⇒ attente infinie » (héritage poll(2) en int millisecondes) est structurellement fermé : seul None est infini.

  • EINTR remonté en AirError de catégorie Interrupted, sans retry (ADR-021 conv.2). L’event loop décide de reboucler.

  • Masque de signaux Option<&SignalMask> : substitution atomique le temps de l’attente (pattern ppoll, ferme la fenêtre de course signal↔poll). None = aucune substitution.

4. Sûreté & no_std

  • Zéro unsafe exposé : tout l’unsafe (l’asm! de ppoll) reste confiné en couche 0. Les descripteurs sont empruntés (BorrowedFd via PollFd, +0) — l’appelant reste seul propriétaire (RAII).
  • #![no_std], zéro allocation : le multiplexeur écrit dans la slice de l’appelant (jamais de Vec interne) — contraste assumé avec air-process::wait_until, qui doit allouer [pidfd, ...wakeups] pour préposer le pidfd. core seul (ADR-048/ADR-055), prouvé par cargo build --lib.
  • Zéro dépendance externe (§2.D doctrine) : seules air-sys-syscall, air-sys-types, air-base-core. Aucune surface C (check-c-surface VERT).

5. Tests & couverture

Cible 100 % lignes + branches (couche 1, ADR-035). Tests sur pipes réels (couche 0 pipe2 + fs::write) : fd prêt en lecture après écriture ; expiration sur pipe muet ; sondage ZERO non bloquant ; timeout négatif dégénéré ; slice vide (sommeil borné) ; multi-fd (seul le prêt porte des revents) ; branche masque Some(_) ; dépouillement ready (compte == retour de poll, entrées prêtes seulement, itérateur vide avant remplissage). Les propriétés de la conversion de timeout sont garanties et property-based en amont (air-base-core::AirDuration::to_std_saturating).

Exception de couverture (1, STRUCTURAL, ADR-035, documentée in-code). Le bras d’échec ppoll (passthrough Err(errno) => Err(errno.into())) est inatteignable de façon déterministe via l’API safe : EINTR (signal — non injectable en CI, choix hérité de la couche 0 scellée et de air-process::wait_forever), EFAULT (exclu : slice/masque vivants par construction), EINVAL (nfds > RLIMIT_NOFILE, ou timespec — ce dernier exclu par la conversion défensive). La conversion Errno → AirError est couverte dans air-base-core. Suivant la convention des exceptions STRUCTURAL uniques de couche 1 (air-memory, air-thread, air-process), elle est marquée in-code (// COVERAGE (STRUCTURAL, ADR-035)), non dans COVERAGE-EXCEPTIONS.md (registre orienté couche 0).

Note fuzz

Aucune cible fuzz. La crate n’ingère aucune donnée externe non structurée : ses entrées sont un &mut [PollFd] (descripteurs empruntés + drapeaux typés), un Option<AirDuration> (timeout typé, conversion déjà fuzzée/property-based côté air-base-core) et un Option<&SignalMask> (bitmask typé). Ni parsing, ni désérialisation, ni buffer d’octets. À l’instar de la section 7 d’air-filesystem (AirFile), l’absence de surface de fuzz est documentée plutôt que matérialisée par une cible vide.

6. Point d’arbitrage — ergonomie du masque de signaux

Le paramètre signal_mask expose directement le type couche 0 [SignalMask], ré-exporté tel quel. C’est le choix le plus fidèle-kernel : ppoll accepte nativement ce masque, et l’exposer débloque immédiatement les event loops signal-safe. En revanche, l’ergonomie de construction d’un masque (dériver depuis un ensemble de Signal, composer/inverser, l’articuler avec un signalfd) relève de la « face signal générique » (roadmap §5.3 P0.4), non encore posée. Tant qu’elle ne l’est pas, l’appelant construit son masque via la surface couche 0 (SignalMask::{empty,from_signals,with,without}). À arbitrer au moment de P0.4 : air-poll continuera-t-il de ré-exporter le SignalMask brut, ou consommera-t-il un type de masque plus riche fourni par la face signal ? La signature de [poll] est conçue pour absorber ce raffinement de manière additive (le paramètre reste un Option<&_>).