ADR-064 — Doctrine des signaux couche 1 : signalfd primaire, fautes → action par défaut (zéro unsafe), helper « bloquer partout + drainer »
Statut : Accepté (2026-07-03, décision BDFL directe). Companion de
ADR-020 (signalfd par défaut) qu’il étend à la
face couche 1 (roadmap §5.3 P0.4) et à la face libc C. RFC de structure
(ADR-015). Cohérent avec
ADR-021 (EINTR remonté, jamais de retry auto)
et avec air-poll (paramètre signal_mask de ppoll).
Catégorie : Doctrine d’architecture couche 1 (face signaux). Surface Rust
scellée en couche-1-v1.0 ; la C-ABI (sigaction/signal) vit au-dessus.
Contexte
Les signaux sont un mécanisme maintenu par le noyau : ensembles pending
(par-thread + par-process ; bitmask coalescé pour les standards, file siginfo pour
les temps-réel), masque bloqué (par-thread), dispositions/handlers
(par-process), décision et frame de délivrance, actions par défaut — tout est
état noyau et piloté noyau. Le userland ne fournit que le code du handler que le
noyau appelle et les wrappers libc. Il n’existe aucun état de signal détenu en
userland.
Linux offre deux façons de consommer ce même état :
- Asynchrone —
sigaction: le noyau détourne le thread vers un handler userland (frame sur la pile). Puissant mais soumis à l’async-signal-safety (handler réentrant, ensemble d’appels autorisés minuscule), non intégrable à une event loop. - Synchrone —
signalfd: on bloque les signaux et on les lit comme un flux d’octets sur un fd, pollable (ppoll/epoll). Pas de handler réentrant : on traite les signaux dans la boucle d’événements normale. C’est le défaut posé par ADR-020.
Décision
1. signalfd = face primaire de la couche 1
Pour les signaux asynchrones/externes (SIGINT, SIGTERM, SIGCHLD, SIGHUP,
SIGWINCH, SIGUSR1/2, temps-réel…), la voie normale est signalfd : consommation
synchrone, thread-safe, déterministe, intégrable à air-poll (le fd
signalfd rejoint l’ensemble surveillé par poll). La couche 0 fournit déjà
signalfd (primauté ADR-020).
2. Fautes synchrones → action par défaut ; la couche 1 Rust n’expose AUCUN handler (décision BDFL 2026-07-03)
Les signaux synchrones de faute — SIGSEGV, SIGFPE, SIGBUS, SIGILL —
ne peuvent pas être différés vers une event loop (traps sur l’instruction fautive).
La seule façon de les traiter serait d’installer un handler — geste
irréductiblement unsafe : la précondition « le handler est async-signal-safe »
est portée par l’appelant et invérifiable par le compilateur (ré-entrance à un
point arbitraire ; malloc/verrou dans le handler = deadlock). std elle-même
n’expose pas sigaction pour cette raison.
Décision : la couche 1 Rust reste 100 % SAFE — elle n’expose AUCUN handler de faute (le runtime installe néanmoins un handler interne minimal de barrière, non exposé — voir §7). Une faute synchrone signifie qu’un invariant amont est déjà violé : la réponse est l’action par défaut du noyau — terminate + core dump, point. Le noyau le fait déjà par défaut ; la couche 1 n’a donc rien à installer. Posture fail-fast (ne rien exécuter dans un contexte corrompu — c’est aussi le choix le plus sûr : les handlers de crash sont une surface d’exploitation classique).
Poser SIG_IGN/SIG_DFL reste safe (constantes noyau, aucun code utilisateur,
aucune ré-entrance) et peut être exposé ; seul l’enregistrement d’un handler
custom est proscrit en couche 1. (Rappel noyau : SIG_IGN sur une vraie faute
matérielle est de toute façon inopérant — Linux force l’action par défaut.)
Corollaire dual-face — comportement figé de la libc C sur les fautes (décision
BDFL 2026-07-03) : les programmes C appellent sigaction. La libc C-ABI
(au-dessus de la couche 1) fournit le symbole sigaction — le code C compile,
lie, et l’appel RÉUSSIT (retour succès, pas d’EINVAL). Mais sur les signaux de
faute (liste à figer ; cœur : SIGSEGV/SIGFPE/SIGBUS/SIGILL) le handler
enregistré n’est JAMAIS installé côté noyau ni appelé : le résultat d’une faute est
toujours l’action par défaut — core dump + terminate, point. On fournit
sigaction pour la compatibilité source, pas pour rendre la main sur les fautes.
Ce n’est pas un manquement à « la libc fait ce qu’elle dit » : Air assume une
position POSIX fonctionnelle, non-conformante (SHOULD→MUST), et ce
comportement est documenté explicitement (transparence). Rationnel : fail-fast
- sécurité (aucun code utilisateur dans un contexte corrompu ; un handler de crash
est une surface d’exploitation). (Détail d’impl différé : si
sigaction(…, NULL, &old)doit refléter la disposition « posée » par l’appelant pour l’illusion d’API, ou rendreSIG_DFL; et le sort desigactionsur les signaux non-faute — émulation via signalfd + dispatch — reste une décision séparée, hors fautes.)
Conséquence assumée : une std/PAL bâtie sur couche 1 perd le message «\ stack
overflow » (qui repose sur un handler SIGSEGV+sigaltstack) → SIGSEGV+core
brut à la place. Acceptable.
3. Helper clé-en-main « bloquer dans tous les threads + drainer via signalfd »
Le pattern signalfd correct est délicat : il faut bloquer le set de
signaux dans tous les threads (sinon la délivrance asynchrone court-circuite le
fd — un signal process-directed part vers n’importe quel thread qui ne le bloque
pas), puis lire depuis le signalfd (souvent depuis un thread/section dédiée). La
couche 1 fournit ce pattern clé-en-main : l’appelant n’a pas à réimplémenter la
gymnastique masque-par-thread + drainage. C’est la valeur ajoutée « fait ce qu’il
dit » d’Air.
4. Pas de SA_RESTART forcé
EINTR est remonté à l’appelant (cohérent air-poll/AirFile, ADR-021
conv.2) : Air ne relance jamais silencieusement un syscall interrompu.
5. Dual-face
La face Rust/PAL penche signalfd (event-loop-friendly, sûre). La face libc
C (au-dessus de la couche 1) fournit sigaction/signal fidèles POSIX pour le
code C (OpenSSH…), honnêtes mais avec avertissements sur les pièges
async-signal-safety. Même brique couche 1, deux faces (motif dual-face habituel).
6. La couche 1 ne maintient aucun état de signal
Elle expose celui du noyau (kernel = bible). La couche 0 a déjà
signalfd/kill/tgkill/sigprocmask ; P0.4 re-expose en couche 1 : création
signalfd + drainage, masquage (sigprocmask/pthread_sigmask), envoi
(kill/tgkill, raise), sigsuspend, et le helper (§3) — aucun handler de
faute (§2). Le masque est par-thread, les dispositions par-process : le
helper gère cette asymétrie pour l’appelant.
7. Fautes synchrones — barrière de poison, handler interne minimal, voies SAFE (décision BDFL 2026-07-03)
Précision essentielle de §2 : « pas de handler de faute » vise le développeur.
Le runtime Air installe, lui, un handler interne — non exposé, prouvé
async-signal-safe, machinerie interne (au même titre que air-runtime::spawn ou le
TLS). Le développeur ne gagne aucun pouvoir : son sigaction sur les fautes
reste inerte (§2, face libc). Distinction : handler développeur (interdit) ≠
handler interne runtime (minimal, contrôlé).
Causes racines des 4 fautes (exceptions matérielles CPU → signal)
Le CPU trappe sur l’instruction fautive ; le noyau, s’il ne peut pas résoudre
(demand paging / COW sont résolus silencieusement), appelle force_sig_fault
(qui force la délivrance — d’où : une vraie faute est inignorable), avec
si_addr/si_code riches — capturés dans le core dump.
| Signal | Cause racine (noyau) | si_code typiques |
|---|---|---|
| SIGSEGV (11) | adresse non mappée (ptr nul/sauvage, UAF, débordement de pile) ; violation de permission (write en RO, exec de page NX, PROT_NONE) | SEGV_MAPERR/SEGV_ACCERR ; SEGV_PKUERR, SEGV_MTE* (arm64) |
| SIGBUS (7) | adresse valide mais accès physique impossible : non-aligné (arm64), mmap au-delà de l’EOF du fichier, erreur mémoire HW (ECC/MCE) | BUS_ADRALN, BUS_ADRERR, BUS_MCEERR_* |
| SIGFPE (8) | division entière par 0 (cas quasi-exclusif), INT_MIN/-1 ; exceptions FP si démasquées (masquées par défaut) | FPE_INTDIV, FPE_INTOVF, FPE_FLT* |
| SIGILL (4) | opcode invalide/indéfini (ud2 de unreachable/UB ; instruction non supportée), instruction privilégiée en user | ILL_ILLOPC, ILL_PRVOPC, … |
(À distinguer : SIGTRAP = débogage ; SIGABRT = auto-infligé par abort()/canari
__stack_chk_fail/panic=abort — pas des fautes matérielles.) Toutes ces
racines = un bug amont (sauf SIGBUS/MCE = panne HW) : le process est déjà
incohérent.
Principe : une barrière de poison (sortir au plus vite, ne rien corrompre)
Quand une de ces 4 fautes survient, le seul objectif est de filer au plus vite vers l’abort + core dump du noyau, sans plus rien toucher — pour (1) préserver l’état mémoire au moment de la faute (core dump fidèle = « la vraie situation » remise au développeur), (2) éviter une double faute, (3) jamais de retry ni de « pseudo-service » dans un espace corrompu. Le handler interne :
- Arme un flag atomique « faute fatale » (process-global +
Tidfautif + n° signal +si_addr) — un simple store, async-signal-safe. - Garde anti-ré-entrance : une faute pendant le handler →
_exitimmédiat. - Au plus une ligne de crash minimale (
writebrut : signal/adresse/thread ; backtrace best-effort, marqué non-fiable — la pile peut être corrompue). - Restaure
SIG_DFLet re-lève → core dump + terminate. « Core dump toujours » (§2) tient ; le handler ne fait que l’annoter, jamais l’empêcher.
Sémantique de barrière côté runtime : une fois le flag armé, le code Air honore
le poison — les primitives runtime (allocateur, I/O fichier, syscalls
d’état) entrées avec le flag armé refusent et filent vers l’abort : aucune
allocation, ouverture, lecture/écriture n’est empilée. « Dégradé » = suppression +
minimum vital, pas d’effort supplémentaire. (Rappel : SIG_DFL sur ces
signaux ne lance de toute façon ni atexit ni destructeurs — rien ne pollue
par défaut ; le flag garantit qu’on ne le fait pas non plus dans la fenêtre
multi-thread de teardown.)
Ce flag n’est pas un miroir de l’état de signal du noyau (§6) : c’est un verrou de mort à sens unique, dérivé, propre au runtime.
Voies SAFE offertes au développeur (« si le noyau le dit, on le dit aussi »)
Sans jamais exposer d’unsafe :
air_runtime::fault_state() -> Option<FaultInfo>: lecture sûre (atomic) de l’état (signal,si_addr,Tid) — un superviseur / un enfant post-forksait qu’une faute a eu lieu, sans handler.userfaultfd(2): pour les fautes de page récupérables (paging applicatif, write-barriers de GC, JIT) — un fd pollable (intégrableair-poll), à la place d’un handlerSIGSEGV. C’est la voie de récupération SAFE, fidèle à la doctrine fd-first.- La ligne de crash minimale (automatique).
Placement
air-runtime porte l’état de faute (process-global, il détient déjà errno/TLS/
atfork) ; air-thread fournit le Tid fautif. Implémentation additive
(le seam étant interne, non-ABI) — le handler interne + fault_state() peuvent
atterrir avant ou après le sceau.
Conséquences
- P0.4 livre une crate/module « face signal » couche 1 (nommage à l’impl) : signalfd
- drainage pollable, masque, envoi, helper « bloquer partout ». Aucun handler de
faute (fautes → action par défaut, §2) → zéro
unsafeexposé.
- drainage pollable, masque, envoi, helper « bloquer partout ». Aucun handler de
faute (fautes → action par défaut, §2) → zéro
- Le paramètre
signal_maskd’air-pollgagne son ergonomie de construction ici (le typeSignalMaskcouche 0, enrichi d’un constructeur couche 1). - La libc C (au-dessus) construit
sigaction/signalsur cette brique. - Zéro dépendance externe ;
no_stdrespecté (drainage =readsur un fd).
Alternatives rejetées
sigaction-first (modèle classique). Rejeté : async-signal-safety, non intégrable à l’event loop, réentrance ; c’est précisément ce qu’ADR-020 fuit.- Exposer un
sigactionde faute (même restreint) en couche 1 Rust. Rejeté (décision BDFL, §2) : il est irréductiblementunsafe, or la couche 1 Rust reste 100 % safe. Les fautes synchrones prennent l’action par défaut (terminate + core) ; toutsigactionpour du code C vit dans la face libc C-ABI au-dessus (régime FFI/unsafe assumé). - Laisser l’appelant gérer le pattern « bloquer partout ». Rejeté : trop d’erreurs subtiles (un thread non bloquant fait fuir un signal hors du fd) — la couche 1 doit offrir le pattern correct clé-en-main.
- Forcer
SA_RESTART. Rejeté (ADR-021 : EINTR remonté).
Suite
- Implémentation P0.4 (voir roadmap §5.3). Tests on-target (signaux réels,
multi-thread, fork) +
air-fault. Puis face Csigaction/signalau-dessus. - Barrière de poison (§7, additif) : handler interne minimal (
air-runtime+Tidd’air-thread), flag atomique de faute honoré par l’allocateur et l’I/O fichier,air_runtime::fault_state()(surface SAFE), et exposition deuserfaultfd(2)comme voie de récupération pollable. Tests on-target (faute réelle provoquée en enfant forké : SIGSEGV/SIGFPE/SIGILL/SIGBUS → flag armé + core dump).