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ément | Contenu |
|---|---|
| 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és | PollFd / 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_setborné àFD_SETSIZE, coût O(max_fd), ré-armement destructif des ensembles). Inférieur àpollsur tous les axes. Une façadeselect/pselectcompatible libc s’exprimera au-dessus depoll, en face-C, si un userland l’exige — Air ne rétrograde pas sa surface native versfd_set(doctrine « variantes modernes », kernel = bible, ADR-021).epoll— surface stateful (epoll_create/_ctl/_wait) relevant du runtime asynchrone io_uring (couche 1air-runtime, ADR-038 : pas d’epoll), pas de ce multiplexeur synchrone one-shot.- La boucle de reprise
EINTRet 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
-
reventsen 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 àreventsnon 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) :Valeur Effet NoneAttente indéfinie (timespec NULL) — seul chemin non bornéSome(AirDuration::ZERO)Sondage non bloquant (revient aussitôt) Some(d),d > 0Attente bornée à dSome(d),d ≤ 0Dégénère en sondage ( ZERO) — jamais infiniLa conversion vers le
timespeccouche 0 passe parAirDuration::to_std_saturating: bornage défensif (durée ≤ 0 →ZERO, secondes clampées àu64::MAX), aucunaslossy (Principe 2). Le piège classique « timeout négatif ⇒ attente infinie » (héritagepoll(2)enintmillisecondes) est structurellement fermé : seulNoneest infini. -
EINTRremonté enAirErrorde catégorieInterrupted, 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
unsafeexposé : tout l’unsafe(l’asm!deppoll) reste confiné en couche 0. Les descripteurs sont empruntés (BorrowedFdviaPollFd, +0) — l’appelant reste seul propriétaire (RAII). #![no_std], zéro allocation : le multiplexeur écrit dans la slice de l’appelant (jamais deVecinterne) — contraste assumé avecair-process::wait_until, qui doit allouer[pidfd, ...wakeups]pour préposer le pidfd.coreseul (ADR-048/ADR-055), prouvé parcargo 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-surfaceVERT).
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<&_>).