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

ADR-019 — Modèle d’erreurs hybride à deux niveaux

Statut : Accepté. Document fondateur de la phase 0.

Catégorie : Architecture (couche 0 et couche 1).

Contexte

Tout au long de la spécification de la couche 0, la question du type d’erreur retourné par les fonctions wrapper s’est posée. Deux préoccupations contradictoires sont apparues :

D’un côté, la couche 0 doit rester minimaliste, performante, et proche des conventions kernel. Un type d’erreur enrichi avec contexte, allocation, et chaîne de causalité serait disproportionné pour une fonction qui wrappe un syscall en quelques instructions assembleur. Le coût d’allocation seul peut être supérieur au coût du syscall lui-même.

De l’autre côté, les couches supérieures qui consomment la couche 0 ont besoin d’erreurs riches en contexte : “impossible d’ouvrir tel fichier à tel chemin pour telle raison” est beaucoup plus utile que “ENOENT”. Sans contexte attaché, l’erreur est difficile à propager utilement vers l’utilisateur final.

Décision

Air adopte un modèle d’erreurs hybride à deux niveaux :

Couche 0 utilise un type Errno minimaliste. Ce type est :

  • #[repr(transparent)] sur NonZeroI32.
  • Sans allocation, sans contexte, sans chaîne de causalité.
  • Une simple énumération des codes d’erreur kernel (EBADF, ENOENT, EINTR, etc.).
  • ~140 constantes correspondant aux codes errno standards de Linux.
  • Implémente core::error::Error pour permettre la chaîne via source() aux couches supérieures.

Couches 1 et au-delà utilisent un type Error enrichi, propre à chaque crate, qui :

  • Capture les erreurs spécifiques au domaine de la crate.
  • Inclut le contexte pertinent (chemins, identifiants, etc.).
  • Encapsule l’Errno source via #[from] pour permettre la propagation avec ?.
  • Est dérivé via thiserror pour réduire le boilerplate.

Conversion entre les deux niveaux

La conversion d’Errno vers Error de la couche supérieure se fait :

  • Automatiquement via #[from] quand le contexte n’est pas pertinent.
  • Explicitement via .map_err(...) quand on attache du contexte (chemin, identifiant).
#![allow(unused)]
fn main() {
// Conversion automatique
fn read_byte(fd: BorrowedFd<'_>) -> Result<u8, IoError> {
    let mut buf = [0u8; 1];
    air_sys_syscall::fs::read(fd, &mut buf)?;  // Errno -> IoError automatique
    Ok(buf[0])
}

// Conversion explicite avec contexte
fn open_config(path: &Path) -> Result<Config, ConfigError> {
    let fd = openat2(DirFd::Cwd, path.as_cstr(), OpenHow::read_only())
        .map_err(|errno| ConfigError::OpenFailed {
            path: path.to_path_buf(),
            source: errno,
        })?;
    // ...
}
}

Justifications

Performance préservée en couche 0. Errno est un NonZeroI32 ; pas d’allocation, pas de heap, taille fixe. Le coût d’une erreur est identique à un return statement classique.

Ergonomie en couches supérieures. Les développeurs d’application Air manipulent des Error riches qui donnent un diagnostic clair. Pas de “ENOENT” lapidaire remonté à l’utilisateur final.

Pas de double système d’erreurs. Un seul type kernel-level (Errno), un type par crate de haut niveau. La hiérarchie est claire et linéaire.

Composition naturelle via ?. Les conversions via #[from] permettent au développeur de propager les erreurs sans cérémonie quand le contexte n’est pas nécessaire.

Conformité au Principe d’ingénierie 4. La validation amont retourne Errno directement pour les violations d’invariants en couche 0, sans surcoût.

Implémentation

Le type Errno est défini dans la crate air-sys-types :

#![allow(unused)]
fn main() {
#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Errno(NonZeroI32);

impl Errno {
    pub const EPERM: Self = Self(unsafe { NonZeroI32::new_unchecked(1) });
    pub const ENOENT: Self = Self(unsafe { NonZeroI32::new_unchecked(2) });
    pub const ESRCH: Self = Self(unsafe { NonZeroI32::new_unchecked(3) });
    pub const EINTR: Self = Self(unsafe { NonZeroI32::new_unchecked(4) });
    pub const EIO: Self = Self(unsafe { NonZeroI32::new_unchecked(5) });
    // ... ~140 constantes au total
    
    pub const fn as_raw(self) -> i32 {
        self.0.get()
    }
    
    pub const fn name(self) -> &'static str {
        match self.as_raw() {
            1 => "EPERM",
            2 => "ENOENT",
            // ...
            _ => "EUNKNOWN",
        }
    }
    
    pub const fn description(self) -> &'static str {
        match self.as_raw() {
            1 => "Operation not permitted",
            2 => "No such file or directory",
            // ...
            _ => "Unknown error",
        }
    }
}

impl core::fmt::Display for Errno {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        write!(f, "{} ({})", self.name(), self.description())
    }
}

impl core::error::Error for Errno {}
}

Les types d’erreur des couches supérieures suivent ce pattern (exemple pour air-runtime-io) :

#![allow(unused)]
fn main() {
use thiserror::Error;
use air_sys_types::Errno;

#[derive(Debug, Error)]
pub enum IoError {
    #[error("syscall failed: {source}")]
    Syscall {
        #[from]
        source: Errno,
    },
    
    #[error("io_uring submission queue full")]
    SubmissionQueueFull,
    
    #[error("operation not supported on this kernel")]
    OperationNotSupported,
}
}

Alternatives considérées et rejetées

Alternative 1 : Errno enrichi avec contexte en couche 0.

Rejetée parce que le coût d’allocation est disproportionné par rapport au coût d’un syscall qui échoue. Pour des fonctions appelées des millions de fois par seconde (lectures, écritures), le surcoût est mesurable.

Alternative 2 : Type d’erreur dynamique (Box<dyn Error>) partout.

Rejetée parce que Box<dyn Error> empêche le pattern matching exhaustif sur les variants d’erreur. Les couches supérieures perdent la capacité de réagir spécifiquement à EAGAIN ou EINTR.

Alternative 3 : Pas de type Errno, juste des i32 bruts.

Rejetée parce que cela perd toute information de typage. Confusion possible entre codes d’erreur et autres valeurs entières, pas de Display formatté, pas d’implémentation de Error.

Conséquences

Conséquence positive principale : la couche 0 reste performante et minimaliste. Les couches supérieures peuvent enrichir les erreurs sans contrainte.

Conséquence négative à accepter : deux types d’erreurs coexistent, ce qui ajoute une étape conceptuelle pour les contributeurs (comprendre quand passer de Errno à Error enrichi). Mitigation : documentation claire dans ERROR_HANDLING.md et exemples canoniques.

Statut futur

ADR immuable dans ses principes. L’évolution des types d’erreur enrichis spécifiques aux crates supérieures se fait normalement, sans amendement de cet ADR.


Licence du document : MPL 2.0 Statut : Document fondateur immuable.