ADR-020 — Stratégie signaux : signalfd par défaut, sigaction restreint
Statut : Accepté. Document fondateur de la phase 0.
Catégorie : Architecture (couche 0).
Contexte
La gestion des signaux Unix est notoirement difficile. Les handlers signaux installés via sigaction doivent respecter la contrainte d’être “async-signal-safe” : ne pas appeler de fonctions non listées comme telles dans man 7 signal-safety. La liste est très restreinte (pas de malloc, pas de printf, pas de lock), et il est facile de la violer involontairement.
Les conséquences d’une violation peuvent être catastrophiques : interblocages, corruption de mémoire, crashes intermittents difficiles à diagnostiquer. Beaucoup de bugs critiques dans les logiciels systèmes proviennent de handlers signaux mal écrits.
Le mécanisme moderne Linux signalfd permet de transformer les signaux en événements lisibles sur un file descriptor. L’application les lit comme n’importe quel autre FD, sans handler installé. Cela élimine structurellement la classe entière de bugs liés aux handlers async-signal-unsafe.
Cependant, signalfd ne couvre pas tous les cas. Les signaux synchrones fatals (SIGSEGV, SIGBUS, SIGFPE, SIGILL) sont délivrés directement à l’instruction fautive et ne peuvent pas être différés via signalfd. Pour ces signaux, un handler sigaction reste nécessaire (typiquement pour des crash reporters qui capturent l’état du processus avant terminaison).
Décision
Air adopte une stratégie en deux temps pour la gestion des signaux :
Mécanisme par défaut : signalfd pour tous les signaux différables.
Tous les signaux que l’application veut gérer (SIGINT, SIGTERM, SIGUSR1, SIGCHLD, etc.) sont gérés via signalfd. Le module air-sys-syscall::signal expose :
signalfd_create: création d’un FD signalfd.signalfd_create_blocking: helper qui combine création + blocage des signaux dans le masque (pattern recommandé).block_signals,unblock_signals,set_signal_mask,current_signal_mask: gestion du masque de signaux.wait_for_signal: helper pour les patterns simples “attendre un signal et faire quelque chose”.kill,tgkill,rt_sigqueueinfo: envoi de signaux.
Mécanisme restreint : sigaction uniquement pour les signaux synchrones fatals.
Un sous-module air-sys-syscall::signal::synchronous_handler expose sigaction mais uniquement pour les 4 signaux synchrones non-différables :
SIGSEGV(segmentation fault).SIGBUS(bus error).SIGFPE(floating point exception).SIGILL(illegal instruction).
Le type FatalSignal est un enum restreint à ces 4 variants. Pas moyen de passer un autre signal à cette API. La barrière est par construction, pas par discipline.
Justifications
Élimination structurelle des bugs async-signal-unsafe. Les développeurs Air n’écrivent pas de handlers signaux pour 99% des cas. Ils lisent un FD comme ils liraient un socket. Pas de risque d’appeler malloc ou printf dans un handler.
Intégration naturelle avec io_uring. Un FD signalfd peut être lu via une opération io_uring. Cela permet à un reactor io_uring d’attendre des signaux dans le même mécanisme que les autres événements asynchrones. Cohérent avec la philosophie d’Air où io_uring est central.
Intégration avec le runtime async (ADR-023). Le runtime asynchrone Air consomme signalfd directement pour la gestion des signaux. Pas besoin de pattern complexe self-pipe trick ou d’intermédiaire en thread dédié.
Conformité au Principe d’ingénierie 7 (verbosité au service de la clarté). Le code qui gère les signaux est explicite : “je lis ce FD, j’obtiens cette structure SignalFdInfo”. Pas de magie cachée dans un handler asynchrone.
Conservation de la capacité à gérer les crashes. Le sous-module synchronous_handler reste disponible pour les cas légitimes (crash reporters, stack guards via sigaltstack). Mais l’API est explicitement étroite et fortement documentée sur ses contraintes.
Implémentation
Le pattern canonique en signalfd :
#![allow(unused)]
fn main() {
use air_sys_syscall::signal::{
signalfd_create_blocking, SignalFdFlags, SignalMask,
};
use air_sys_types::Signal;
let mask = SignalMask::from_signals(&[
Signal::SIGTERM,
Signal::SIGINT,
Signal::SIGUSR1,
]);
let sfd = signalfd_create_blocking(&mask, SignalFdFlags::empty())?;
loop {
let info = sfd.read()?;
match info.signal {
Signal::SIGTERM | Signal::SIGINT => {
println!("Shutdown signal received");
break;
}
Signal::SIGUSR1 => {
println!("Custom signal received");
}
_ => unreachable!(),
}
}
}
Le pattern pour les signaux fatals (cas avancé) :
#![allow(unused)]
fn main() {
use air_sys_syscall::signal::synchronous_handler::{
install_fatal_handler, FatalSignal, SignalInfo,
};
unsafe extern "C" fn crash_handler(
signum: c_int,
info: *mut SignalInfo,
context: *mut c_void,
) {
// Code TRES restreint : uniquement async-signal-safe.
// Typiquement : écrire un mini-dump puis _exit.
}
// SAFETY: handler is async-signal-safe (only calls write and _exit).
unsafe {
install_fatal_handler(FatalSignal::Segv, crash_handler)?;
}
}
L’API unsafe du sous-module est obligatoire et la documentation # Safety est extensive.
Alternatives considérées et rejetées
Alternative 1 : sigaction pour tout, comme la convention Unix historique.
Rejetée. Productrice de bugs subtils, incompatible avec une intégration io_uring propre, demande une discipline soutenue que les développeurs perdent inévitablement.
Alternative 2 : signalfd pour tout, y compris les fatals.
Impossible. Les signaux synchrones fatals ne sont pas différables : ils sont délivrés au moment de l’instruction fautive, à l’endroit de cette instruction. Ils ne peuvent pas être attrapés via signalfd.
Alternative 3 : self-pipe trick (pattern classique).
Le self-pipe trick consiste à installer un handler signal minimal qui écrit dans un pipe, puis à lire le pipe dans le reactor. Fonctionnel mais plus complexe que signalfd (qui fait essentiellement la même chose en interne avec plus de garanties).
Conséquences
Conséquence positive principale : élimination d’une classe entière de bugs systèmes critiques. Les futurs développeurs Air n’auront jamais à débugger un handler signal qui appelle indirectement malloc.
Conséquence à accepter : un sous-module unsafe reste exposé pour les fatals. Mitigation : périmètre étroit (4 signaux seulement), API explicite, documentation # Safety substantielle, revue obligatoire pour toute utilisation.
Statut futur
ADR immuable dans ses principes. Des ajouts au sous-module synchronous_handler (par exemple, support de sigaltstack pour les stack guards) peuvent se faire sans amendement, tant qu’ils respectent la discipline (périmètre étroit, sécurité par construction).
Licence du document : MPL 2.0 Statut : Document fondateur immuable.