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-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 :

  • Asynchronesigaction : 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.
  • Synchronesignalfd : 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 fauteSIGSEGV, 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 noyauterminate + 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 (SHOULDMUST), 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 rendre SIG_DFL ; et le sort de sigaction sur 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 internenon 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.

SignalCause 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 userILL_ILLOPC, ILL_PRVOPC, …

(À distinguer : SIGTRAP = débogage ; SIGABRT = auto-infligé par abort()/canari __stack_chk_fail/panic=abortpas 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 :

  1. Arme un flag atomique « faute fatale » (process-global + Tid fautif + n° signal + si_addr) — un simple store, async-signal-safe.
  2. Garde anti-ré-entrance : une faute pendant le handler → _exit immédiat.
  3. Au plus une ligne de crash minimale (write brut : signal/adresse/thread ; backtrace best-effort, marqué non-fiable — la pile peut être corrompue).
  4. Restaure SIG_DFL et 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-fork sait 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égrable air-poll), à la place d’un handler SIGSEGV. 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 unsafe exposé.
  • Le paramètre signal_mask d’air-poll gagne son ergonomie de construction ici (le type SignalMask couche 0, enrichi d’un constructeur couche 1).
  • La libc C (au-dessus) construit sigaction/signal sur cette brique.
  • Zéro dépendance externe ; no_std respecté (drainage = read sur 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 sigaction de faute (même restreint) en couche 1 Rust. Rejeté (décision BDFL, §2) : il est irréductiblement unsafe, or la couche 1 Rust reste 100 % safe. Les fautes synchrones prennent l’action par défaut (terminate + core) ; tout sigaction pour 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 C sigaction/signal au-dessus.
  • Barrière de poison (§7, additif) : handler interne minimal (air-runtime + Tid d’air-thread), flag atomique de faute honoré par l’allocateur et l’I/O fichier, air_runtime::fault_state() (surface SAFE), et exposition de userfaultfd(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).