Une sortie qui s'évapore : passé 1024 fichiers ouverts, les appels terminal de Hermes reviennent vides


Vendredi après-midi : l’agent annonce que la suite de tests est terminée. Vous lui demandez de coller la sortie et il renvoie deux guillemets vides — {"output": "", "exit_code": 0, "error": null}. Vous lui demandez de read_file une config de 5 Ko : il rapporte file_size: 0. Vous faites un cat vous-même, le fichier est parfaitement intact. Bizarrement, le même code Python lancé via execute_code lit son contenu sans problème. Comme si seuls certains canaux étaient devenus aveugles.

Le modèle ne vous fait pas obstruction et le disque va bien. C’était un bug resté ouvert près de deux mois et clos le 21 octobre 2026 (issue #94928, PR #142299), et la cause racine tient en une ligne : dès qu’un processus détient plus de 1024 descripteurs de fichiers, l’appel select() qui attend la sortie lève une exception — et cette exception était traitée comme « le pipe est fermé ».

Le symptôme : sortie vide, code de sortie à 0

Le rapport d’origine est précis. Sous Linux, après un certain temps de fonctionnement de Hermes Desktop, terminal renvoyait une stdout vide pour chaque commande tout en annonçant un code de sortie 0. ls et stat revenaient vides ; read_file retournait file_size: 0 pour un fichier de 5092 octets ; Python dans execute_code lisait correctement le même chemin.

Pourquoi une sortie vide plutôt qu’une erreur ? Parce que le thread lecteur abandonnait avant d’avoir lu quoi que ce soit : il décidait que le pipe était fermé et jetait tout ce qui n’était pas encore arrivé, tandis que la commande s’exécutait jusqu’au bout et renvoyait 0. Côté appelant, cela ressemble à un appel réussi qui n’avait simplement rien à dire.

Le rapport contient les chiffres qui l’expliquent : le processus main d’Electron détenait 1438 fd, deux processus hermes serve en détenaient 1429 et 1410, et un enfant git-remote-https 1384. Tous au-dessus de la ligne.

Cause racine : le plafond de 1024 dans select()

select() de POSIX représente les ensembles de fd comme une bitmap de taille fixe, dont la longueur est FD_SETSIZE, généralement compilée à 1024. Passez-lui un descripteur supérieur ou égal à 1024 et Python lève :

ValueError: filedescriptor out of range in select()

Hermes lit la sortie des commandes à trois endroits — le drain au premier plan (_drain_fd_select dans tools/environments/base_output.py), le lecteur de processus en arrière-plan (_reader_loop dans tools/process_registry.py), et le PtyBridge derrière le volet terminal de Desktop. Les trois attendaient dans select.select(), et les trois traitaient ce ValueError comme « le fd est déjà fermé ». C’est pourquoi la même sortie perdue apparaissait aussi bien sur les commandes au premier plan, les jobs d’arrière-plan que le volet terminal de Desktop.

Pas besoin de Hermes pour reproduire : ouvrez 1100 handles vers /dev/null, créez un pipe, et appelez select.select() sur le nouveau descripteur. L’exception arrive immédiatement.

Le correctif : remplacer select(), mais pas par poll()

Le patch ajoute tools/environments/fd_wait.py, un petit FdWaiter construit sur selectors.DefaultSelector — epoll sous Linux, kqueue sous macOS, aucun des deux n’ayant de plafond FD_SETSIZE.

class FdWaiter:
    """Readability wait on one fd. Owns a selector (epoll/kqueue are fds themselves):
    create it once per reader lifetime and close() it when the reader is done."""

    def __init__(self, fd: int, selector_cls):
        self._selector = selector_cls()
        try:
            self._selector.register(fd, selectors.EVENT_READ)
        except BaseException:
            self._selector.close()
            raise

    def wait(self, timeout_s: float) -> bool:
        return bool(self._selector.select(max(timeout_s, 0)))

Un choix contre-intuitif : poll() n’a pas de limite à 1024 non plus, alors pourquoi ne pas s’en servir ? Parce que poll() sous macOS ne gère pas les fd de périphérique — il signale un maître PTY comme POLLNVAL, c’est-à-dire « prêt tout de suite ». Une boucle de lecture PTY bâtie sur poll() tournerait à plein régime dès qu’elle est inactive, en brûlant un cœur. poll() ne survit donc que comme repli là où rien d’autre n’est possible : contrairement à epoll et kqueue, il n’a pas besoin d’un fd à lui, ce qui lui permet de fonctionner sous EMFILE ; sous macOS, pour un périphérique de type caractère (PTY), le code lève une erreur plutôt que d’accepter une attente qui n’attend jamais.

Le patch ferme aussi deux angles : un échec d’initialisation du sélecteur ne doit jamais être signalé comme une fin de flux, et chaque lecteur possède un waiter pour toute sa durée de vie et le ferme à la fin, de sorte que corriger l’ancienne fuite n’en introduit pas une nouvelle.

Comment savoir si vous avez été touché

Un tableau de symptômes vaut mieux qu’une exploration de logs : la commande a réellement tourné (les effets de bord sont là), mais output est une chaîne vide, exit_code vaut 0 et error est null ; read_file annonce 0 octet pour un fichier non vide ; execute_code lit le même fichier sans problème. Les trois ensemble, et vous pouvez arrêter de deviner.

Compter les descripteurs confirme le diagnostic. Sous Linux :

for p in $(pgrep -f "hermes_cli.main serve"); do
  echo "$p: $(ls /proc/$p/fd | wc -l) fds"
done

Sous macOS :

lsof -p $(pgrep -f "hermes_cli.main serve" | head -1) | wc -l

ulimit -n n’est qu’un contexte — macOS part souvent de 256, Linux de 1024 — car la ligne qui compte est FD_SETSIZE, pas la limite du shell.

Sur une version plus ancienne, le palliatif est brutal : redémarrer le gateway (et Desktop), faire redescendre le nombre de fd, et la sortie revient aussitôt. Et ne lisez jamais « sortie vide avec exit 0 » comme « la commande n’a rien affiché » ; un résultat vide d’apparence plausible est la chose la plus facile à inscrire dans une conclusion.

Les services de longue durée accumulent des descripteurs quoi qu’on y exécute : il ne s’agit donc ni de conteneurs ni de la configuration Docker à conteneur partagé — il s’agit du temps de fonctionnement du processus et du nombre de connexions. Pour un terrain opérationnel voisin, voir la récupération des processus orphelins et le guide du répertoire temporaire du terminal ; les commandes du gateway sont dans la référence hermes gateway.

Peut-on l’utiliser aujourd’hui ?

Le patch a été mergé dans main le 21 octobre 2026 et n’est pas encore dans une release (la dernière version publiée, la v0.22.2, lui est antérieure). Il arrivera avec le prochain patch. Pour le voir à l’œuvre dès maintenant, retenez environ 1100 descripteurs dans un processus et lancez echo : sur l’ancien code la sortie disparaît, sur le nouveau elle revient.

En bref

Trois choses à retenir. Une sortie vide avec exit 0 signifie une sortie perdue, pas une sortie vide. La cause est le plafond de 1024 descripteurs de select(), ni la mémoire, ni le disque, ni les permissions. Le correctif utilise un sélecteur sans plafond et évite délibérément poll() sous macOS, qui tournerait à vide. Quand un problème ne touche que les instances de longue durée, comptez les descripteurs de fichiers avant de plonger dans les logs.