Ausgabe, die verschwindet: ab 1024 offenen Dateien liefern Hermes-Terminal-Aufrufe leere Ergebnisse


Freitagnachmittag: der Agent meldet, die Testsuite sei durchgelaufen. Du bittest ihn, die Ausgabe zu zeigen, und bekommst zwei leere Anführungszeichen — {"output": "", "exit_code": 0, "error": null}. Bittest du ihn, eine 5-KB-Config mit read_file zu lesen, meldet er file_size: 0. Du cat-st die Datei selbst, und sie ist völlig in Ordnung. Seltsamerweise liest dasselbe Python über execute_code den Inhalt ohne Probleme. Als wären nur einige Kanäle blind geworden.

Das Modell mauert nicht, und die Platte ist in Ordnung. Das war ein Bug, der fast zwei Monate offen blieb und am 21. Oktober 2026 geschlossen wurde (issue #94928, PR #142299), und die Ursache ist eine Zeile: sobald ein Prozess mehr als 1024 File-Descriptors hält, wirft der select()-Aufruf, der auf Ausgabe wartet, eine Exception — und diese Exception wurde als „die Pipe ist geschlossen“ behandelt.

Das Symptom: leere Ausgabe, erfolgreicher Exit-Code

Der ursprüngliche Report ist präzise. Unter Linux lieferte terminal nach einiger Laufzeit von Hermes Desktop für jeden Befehl leeres stdout, bei Exit-Code 0. ls und stat kamen leer zurück; read_file meldete file_size: 0 für eine 5092-Byte-Datei; Python in execute_code las denselben Pfad korrekt.

Warum leere Ausgabe statt eines Fehlers? Weil der Reader-Thread aufgab, bevor er etwas gelesen hatte: er entschied, die Pipe sei geschlossen, und verwarf alles, was noch nicht angekommen war, während der Befehl selbst zu Ende lief und 0 zurückgab. Aus Sicht des Aufrufers sieht das nach einem erfolgreichen Aufruf aus, der einfach nichts zu sagen hatte.

Der Report enthält die Zahlen, die es erklären: Electron main hielt 1438 fds, zwei hermes serve-Prozesse hielten 1429 und 1410, und ein git-remote-https-Kindprozess hielt 1384. Alle über der Grenze.

Ursache: die 1024er-Obergrenze in select()

POSIX-select() stellt fd-Mengen als Bitmap fester Größe dar, deren Länge FD_SETSIZE ist, üblicherweise als 1024 kompiliert. Gib ihm einen Descriptor ab 1024, wirft Python:

ValueError: filedescriptor out of range in select()

Hermes liest Befehlsausgabe an drei Stellen — der Foreground-Drain (_drain_fd_select in tools/environments/base_output.py), der Reader für Hintergrundprozesse (_reader_loop in tools/process_registry.py) und die PtyBridge hinter dem Terminal-Pane des Desktop. Alle drei warteten in select.select(), und alle drei behandelten diesen ValueError als „der fd ist bereits geschlossen“. Deshalb trat dieselbe verlorene Ausgabe bei Vordergrundbefehlen, Hintergrundjobs und dem Desktop-Terminal-Pane gleichermaßen auf.

Du brauchst Hermes nicht, um es zu reproduzieren: öffne 1100 Handles auf /dev/null, erstelle eine Pipe und rufe select.select() auf dem neuen Descriptor auf. Die Exception kommt sofort.

Der Fix: select() ersetzen, aber nicht durch poll()

Der Patch fügt tools/environments/fd_wait.py hinzu, einen kleinen FdWaiter auf Basis von selectors.DefaultSelector — epoll unter Linux, kqueue unter macOS, keines der beiden hat eine FD_SETSIZE-Obergrenze.

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

Eine kontraintuitive Entscheidung: poll() hat ebenfalls kein 1024-Limit, warum also nicht verwenden? Weil poll() unter macOS keine Device-fds unterstützt — es meldet einen PTY-Master als POLLNVAL, also als „sofort bereit“. Eine PTY-Leseschleife auf poll()-Basis würde im Leerlauf mit voller Geschwindigkeit drehen und einen Kern verbrennen. Deshalb überlebt poll() nur als Fallback, wo nichts anderes möglich ist: anders als epoll und kqueue braucht es keinen eigenen fd und funktioniert damit noch unter EMFILE; unter macOS wirft der Code bei einem Character Device (PTY) lieber, als ein Warten zu akzeptieren, das nie wartet.

Der Patch schließt außerdem zwei Kanten: ein Fehler beim Selector-Setup darf niemals als EOF gemeldet werden, und jeder Reader besitzt einen Waiter für seine Lebensdauer und schließt ihn danach — damit der Fix des alten Leaks kein neues einführt.

Woran du erkennst, ob es dich getroffen hat

Eine Symptomtabelle schlägt Log-Graben: der Befehl lief wirklich (Nebenwirkungen sind vorhanden), aber output ist ein leerer String, exit_code ist 0 und error ist null; read_file meldet 0 Bytes für eine nicht-leere Datei; execute_code liest dieselbe Datei problemlos. Alle drei zusammen, und du kannst aufhören zu raten.

Descriptors zählen bestätigt es. Unter Linux:

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

Unter macOS:

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

ulimit -n ist nur Kontext — macOS startet oft bei 256, Linux bei 1024 — weil die entscheidende Zeile FD_SETSIZE ist und nicht das Shell-Limit.

Auf einem älteren Build ist der Notbehelf grob: Gateway neu starten (und Desktop), die fd-Zahl wieder runterdrücken, und die Ausgabe ist sofort zurück. Und lies „leere Ausgabe mit Exit 0“ niemals als „der Befehl hat nichts ausgegeben“; ein plausibel aussehendes leeres Ergebnis ist genau das, was am leichtesten in eine Schlussfolgerung wandert.

Langlebige Dienste sammeln Descriptors an, egal was du in ihnen laufen lässt — es geht hier also nicht um Container oder das shared Docker container setup, sondern um Prozess-Uptime und Verbindungszahl. Für benachbarte Betriebsthemen siehe orphan process reclamation und den terminal temp directory guide; die Gateway-Befehle stehen in der hermes gateway reference.

Kannst du es heute nutzen?

Der Patch wurde am 21. Oktober 2026 nach main gemergt und ist noch in keinem Release (die letzte veröffentlichte Version, v0.22.2, ist älter). Er kommt mit dem nächsten Patch. Um es jetzt zu sehen: halte rund 1100 Descriptors in einem Prozess und führe echo aus — im alten Code ist die Ausgabe weg, im neuen kommt sie zurück.

Die Kurzfassung

Drei Dinge zum Merken. Leere Ausgabe mit exit 0 bedeutet verlorene Ausgabe, nicht leere Ausgabe. Die Ursache ist die 1024-Descriptor-Obergrenze von select(), nicht Speicher, Platte oder Rechte. Der Fix nutzt einen Selector ohne Obergrenze und meidet poll() unter macOS bewusst, weil es drehen würde. Wenn ein Leiden nur langlebige Instanzen trifft, zähle die File-Descriptors, bevor du Logs liest.