Interblocage des pompes de redirection — ce que la lecture du code a établi
Date : 2026-08-13. Statut : enquête ouverte, cause non établie.
Le fait
Le 2026-08-12, la suite complète d’air-sshd lancée en root sur carbon s’est bloquée
2 h 22 min sur forward_demux_blocking::a_forward_joins_a_session_already_under_way. Deux
fils, chacun dans un ppoll sans échéance, un port d’écoute toujours tenu. Arrêt manuel.
#0 air_sys_syscall::poll::raw_syscall_ppoll (fds=…, nfds=2, timeout=0, …)
#0 air_sys_syscall::poll::raw_syscall_ppoll (fds=…, nfds=3, timeout=0, …)
timeout=0 est le pointeur nul de ppoll : attendre pour toujours. Les deux comptes de
descripteurs correspondent au harnais de test (client + pidfd de session) et à la boucle du
monitor (pré-auth + médiation + pidfd de session).
Ni le socket client, ni le canal de médiation, ni le pidfd n’étaient lisibles : le processus de session était donc vivant, et personne n’avait rien à dire.
Jamais reproduit en isolation — seulement en suite complète. C’est la signature d’une course sensible à l’ordonnancement, et c’est probablement ce qui se cache derrière les rouges intermittents de cette famille en CI (un job expiré à 60 minutes sans une ligne).
Ce que la lecture a ÉLIMINÉ
Trois hypothèses plausibles, écartées avec leur raison. Les consigner évite qu’on les reparcoure.
1. La demande de SIGHUP perdue en silence — NON
SessionDriver::hang_up fait let _ = requester.send_session_control(SessionControl::Hangup),
et ignore donc l’échec. Cela ressemble à un trou.
Ce n’en est pas un : ForwardRequester::send empoisonne le requester dès qu’une émission
échoue. Un Hangup perdu implique un canal condamné, donc une connexion qui se termine de
toute façon. La justification écrite (« un geste de confort perdu n’est pas une raison de
refuser une session ») tient.
2. SIGHUP ignoré par héritage à travers l’execve — NON
Le dépôt sait que SIG_IGN survit à l’exec : c’est écrit dans sigpipe.rs, et c’est
pourquoi le shell de session retrouve SIG_DFL sur SIGPIPE avant son execve. Si SIGHUP
était masqué quelque part sans être restauré, le shell l’ignorerait et ne mourrait jamais.
Vérifié : air-sshd ne touche à aucune disposition sauf SIGPIPE, et la restaure. Ni
signalfd, ni sigprocmask, ni masque hérité.
3. Le monitor cesse de lire pendant qu’il doit une annonce — NON
Quand la mort est constatée mais que l’annonce n’a pas pu être écrite, la boucle du monitor
passe en awaiting_writable. Si elle n’attendait alors que la place d’écrire, un worker
en train de remplir sa moitié bloquerait les deux — interblocage classique de tampons pleins.
Vérifié : forward_events vaut IN | OUT dans ce mode. Le monitor continue de lire.
Ce que la lecture a ÉTABLI
Le test attend davantage que ce que le pilote promet.
session_blocking.rs énonce son contrat sans ambiguïté :
Un
CHANNEL_CLOSEne fait plus que signaler et marquer le canal en fermeture ; la finalisation vient quand la mort est constatée. Si elle ne vient jamais, le canal reste semi-fermé et les redirections, elles, continuent d’être servies.
C’est un choix délibéré de la V2.6-3 C.5c-fix : un processus qui piège SIGHUP ne doit pas
figer les redirections qui coexistent. Du point de vue du démon, l’état observé est
conforme — session semi-fermée, forwarding servi.
Mais collect_session_end attend le CHANNEL_CLOSE de la session sans condition. Il
encode donc une hypothèse plus forte que le contrat : ce shell-ci meurt sur SIGHUP. Quand
elle est fausse — ou seulement lente —, le test n’échoue pas : il pend.
Cela déplace la question. Le rouge intermittent de la CI n’est peut-être pas un défaut du démon, mais un test qui attend une garantie que personne n’a donnée.
Ce qui reste à trancher
La lecture ne peut pas dire lequel des deux s’est produit :
- le shell n’est pas mort — le
SIGHUPn’a pas été délivré, ou pas au bon processus ; - il est mort sans que la mort soit constatée — le pidfd n’était pas surveillé au bon moment, ou l’annonce s’est perdue entre monitor et worker.
Les deux produisent exactement l’état observé, et aucun n’est distinguable a posteriori d’une pile figée.
#706 tranche à la prochaine occurrence : l’attente est désormais bornée à 60 s et le test échoue en nommant ce qu’il attendait et si une session était encore suivie par pidfd. C’est la différence entre un job expiré sans une ligne et un rouge qui dit où regarder.
Complété le 2026-08-13 — serve_session_control attendait « un message de médiation
servi » et non « le raccrochage a eu effet ». Il vérifiait donc quelque chose de plus faible
que ce dont le test a besoin : servi le mauvais message, l’assertion passait quand même.
Le canal de médiation étant une file, tout message doublant le Hangup l’aurait laissé
non servi pour toujours — plus personne n’appelant serve_once ensuite —, produisant
exactement le blocage observé. Cette hypothèse n’est pas démontrée pour ce test : la
lecture montre que tous les envois du worker vers le monitor sont des paires
demande/réponse, déclenchées par une action du client et déjà servies au moment du
raccrochage. Il n’y a donc, ici, pas de doubleur identifié.
La fonction attend désormais la mort du processus — seule preuve que le SIGHUP est
parti et a porté. Ce n’est pas présenté comme une correction de l’interblocage : c’est le
déplacement du point d’échec là où la chaîne casse. Les deux branches restées
indistinguables ci-dessus le deviennent :
- l’échec tombe dans
serve_session_control⇒ le processus n’est pas mort ; - il tombe plus loin, dans l’attente bornée de #706 ⇒ il est mort sans que la mort soit constatée.
Si l’on veut provoquer plutôt qu’attendre
Le seul périmètre où le blocage s’est jamais montré est la suite complète en root. Deux passes ont coûté plus de deux heures pour une occurrence. Le rendement est faible ; attendre que #706 parle est probablement plus économique.