Zum Inhalt springen
Bernhard Götzendorfer
Debugging & Lösungen

Eine Menubar-App killt meine Zombie-Prozesse

Meine Agenten hinterlassen Zombie-Prozesse, die still meinen RAM fressen. Also habe ich mir eine kleine macOS-Menubar-App gebaut, die sie findet und killt.

Editorial-Tuschezeichnung in dunklen Sepia- und Bernsteintönen auf Off-White-Hintergrund: eine wachsame Wächter-Silhouette über einem Feld verstreuter, erloschener Prozessknoten, Fokus auf Aufräumen
KI-generierte Illustration

TL;DR

Ich fahre oft ein Dutzend paralleler Agent-Sessions auf einem MacBook mit 24 GB Speicher. Jede Session startet Testläufe, Typecheck-Builds und MCP-Server. Wird eine Session abgebrochen, bleiben die Kindprozesse hängen: verwaiste Vitest-Worker, hängende Jest-Runner, vergessene tsc-Builds, unbenutzte MCP-Server und Playwright-Browser, die ihren Testlauf überleben. Sie hängen sich an PID 1, laufen weiter und fressen meinen RAM. Also habe ich mir ein kleines Werkzeug gebaut: DevWatchdog, eine macOS-Menubar-App in Swift, die alle 30 Sekunden scannt, 30 Sekunden Gnadenfrist gibt und die Waisen dann killt. Ehrlich vorweg: Das ist ein persönliches Experiment, kein Produkt. Es liegt inzwischen im Regal.

Das Problem: Meine Agenten hinterlassen Leichen

Agentisches Bauen ist ein Prozess-Fest. Ein einzelner pnpm test startet eine ganze Baumkrone an Fork-Workern. Ein Typecheck-Lauf, ein paar MCP-Server, ein Playwright-Durchlauf mit echten Browsern dazu. Solange die Session lebt und sauber beendet, räumt der Elternprozess seine Kinder wieder ab. Das Problem beginnt, wenn die Session eben nicht sauber beendet.

Ich breche Sessions ständig ab. Ein Agent läuft in die falsche Richtung, ich drücke Strg-C, starte neu. Der Editor stürzt ab. Ein Testlauf hängt und ich kille das Terminal. In all diesen Fällen bleibt der Elternprozess auf der Strecke, aber seine Kinder nicht. macOS hängt die verwaisten Prozesse an PID 1 um, und dort laufen sie munter weiter. Ein Vitest-Worker weiß nicht, dass sein Auftraggeber tot ist. Er wartet auf Arbeit, die nie kommt, und belegt dabei seinen Speicher.

Auf einer großen Maschine merkt man das kaum. Auf 24 GB, mit einem Dutzend paralleler Sessions, merkt man es sofort. Der Speicherdruck steigt, macOS beginnt zu swappen, und die eine Session, an der ich gerade wirklich arbeite, wird zäh. Warum ausgerechnet 24 GB bei mir jede Leiche wehtut, habe ich in 24-GB-Mac: Warum mein lokales NVFP4-Setup scheiterte ausführlicher beschrieben.

Das README von DevWatchdog zeigt als Beispiel Dutzende verwaister Worker auf einmal. Das ist illustratives UI-Copy, keine Messung, aber es trifft das Gefühl. Wenn man den Tag über zwanzig Mal eine Session abbricht, sammelt sich der Müll. Mein alter Reflex war ein regelmäßiges pkill -f vitest im Terminal. Grob, riskant, und ich habe es ständig vergessen. Ein kleiner Wächter, der das selbst macht, war die naheliegende Antwort.

Wie es funktioniert: Scan, Gnadenfrist, Kill

Die Mechanik ist bewusst schlicht. Drei Schritte, kein Modell, keine Cloud, kein Account:

  • Scan alle 30 Sekunden. Ein Timer sieht sich die laufenden Entwicklungs-Prozesse an und gleicht sie gegen ein Muster ab: Vitest-Worker, Jest-Runner, tsc- und tsgo-Builds, MCP-Server, Playwright-Browser. Alle 30 Sekunden, nicht dauernd, damit das Ding selbst keine spürbare Last erzeugt.
  • 30 Sekunden Gnadenfrist. Ein Prozess, der frisch als Kandidat auffällt, wird nicht sofort gekillt. Er bekommt eine Frist von 30 Sekunden. Das fängt den häufigsten Fehlalarm ab: ein Prozess, der gerade erst startet und noch keinen sauberen Elternbezug hat. Wer sich in der Frist wieder normalisiert, überlebt.
  • Orphan-Heuristik über PPID. Das primäre Kriterium ist simpel und robust: Ist der Prozess eine Waise? Ein Prozess mit Parent-PID 1 hat keinen lebenden Auftraggeber mehr. Genau das ist die Signatur eines Zombies aus einer abgebrochenen Session. Statt nach RAM-Schwellen zu raten, killt DevWatchdog das, was nachweislich niemandem mehr gehört.

Diese Reihenfolge ist der ganze Punkt. Erst beobachten, dann warten, dann handeln. Ein sofortiger Kill nach RAM-Verbrauch würde genau die Läufe treffen, die gerade produktiv Speicher belegen. Die Waisen-Heuristik trifft dagegen fast nur das, was wirklich tot ist.

Emergency Mode und Prozess-Limits

Über der Grundmechanik liegen zwei Sicherungen für den Fall, dass es doch eng wird:

  • Emergency Mode. Wenn der 5-Minuten-Load-Average einen Schwellwert überschreitet, schaltet die App in einen schärferen Modus. Der Schwellwert ist emergencyLoadFactor × CPU-Anzahl, per Default das 1,5-fache der Kerne. Das ist der Moment, in dem die Maschine spürbar ins Straucheln kommt, dann greift der Wächter härter durch, statt die nächste sanfte Runde abzuwarten.
  • Limits pro Prozess-Typ. Nicht jeder Prozess ist gleich verdächtig. Ein Vitest-Worker, der 20 Minuten lebt, ist mit hoher Wahrscheinlichkeit vergessen. Ein MCP-Server darf dagegen stundenlang laufen, das ist normal. Also gibt es pro Typ eine eigene Obergrenze: Vitest-Worker rund 20 Minuten, tsgo etwa 5, tsc etwa 8, MCP-Server rund 4 Stunden, und ein Auffangwert von etwa 8 Stunden für alles andere. Wer sein Limit reißt, fliegt.

Die Zahlen sind keine Wissenschaft, sondern Bauchgefühl aus meiner eigenen Nutzung, in Konfiguration gegossen. Genau das ist der Vorteil einer Sache, die man nur für sich baut: Man darf die Schwellen an das eigene Verhalten anpassen, statt einen Kompromiss für fremde Setups zu suchen.

Agentisches Bauen erzeugt Nebenwirkungen. Ein Teil der Arbeit ist, den eigenen Müll wieder wegzuräumen, am besten so, dass man nicht daran denken muss.

Der Stack: Swift, MenuBarExtra und ein Gatekeeper-Warnhinweis

Warum eine native App und kein Shell-Skript? Weil eine Menubar-App unaufdringlich mitläuft. Ein kleines Symbol oben rechts, ein Klick zeigt, was gerade läuft und was gekillt wurde. Kein Terminal-Fenster, das ich offen halten und beobachten muss.

Technisch ist es bewusst dünn: Swift 6.0, SwiftUI mit MenuBarExtra für das Menüleisten-Symbol, Ziel macOS 15.0 aufwärts, gebaut mit Xcode 16.2. Verteilt als DMG, signiert mit einer Developer-ID, aber noch nicht notarisiert. Das heißt: Gatekeeper warnt beim ersten Start, man muss die App einmal manuell freigeben. Für ein Ding, das nur auf meinem eigenen Rechner läuft, war mir die Notarisierung den Aufwand bisher nicht wert. Es ist eine lokale macOS-App, MIT-lizenziert, und sie telefoniert nirgendwo hin.

Warum das Ding im Regal liegt

Jetzt der ehrliche Teil, der in solchen Maker-Storys gern fehlt: DevWatchdog ist kein Produkt. Es ist ein Wochenend-Experiment, das seinen Zweck erfüllt und dann liegen geblieben ist. In meinem eigenen Werkzeug-Verzeichnis ist es als archiviert markiert. Ich will hier nichts verkaufen, und ich behaupte nicht, dass es bei irgendjemandem außer mir sauber läuft.

Drei ehrliche Gründe, warum es im Regal liegt:

  • Prozesse killen ist heikel. Die Waisen-Heuristik ist gut, aber sie ist eine Heuristik. Einen langlebigen Prozess fälschlich zu töten ist ein echtes Risiko, kein theoretisches. Ein Werkzeug, das automatisch killt, muss man wirklich vertrauen, und dieses Vertrauen habe ich nur für meinen eigenen, engen Anwendungsfall aufgebaut.
  • Die fehlende Notarisierung ist eine Hürde. Der Gatekeeper-Warnhinweis ist für mich egal, für jeden anderen wäre er der erste Reibungspunkt. Das sauber zu lösen wäre Arbeit, die ich nur investieren würde, wenn es ein Produkt werden sollte. Soll es aber nicht.
  • Der ehrlichere Fix liegt tiefer. Die sauberste Lösung wäre, dass gar keine Waisen entstehen: bessere Prozess-Bäume, konsequentes Aufräumen beim Session-Abbruch. DevWatchdog behandelt ein Symptom. Das ist okay für ein persönliches Werkzeug, aber es ist der Grund, warum ich es nicht zum Flaggschiff aufblase.

Trotzdem war es die zwei Abende wert. Es tut, was es soll, es hat mir seither viele manuelle pkill-Runden erspart, und es ist ein sauberes kleines Beispiel dafür, wie ich arbeite: Reibung bemerken, ein enges Werkzeug bauen, es ehrlich einordnen. Nicht jedes gebaute Ding muss ein Produkt werden.

Fazit

Was ich aus DevWatchdog mitnehme, ist weniger die App als die Haltung dahinter:

  1. Agentisches Bauen räumt auch seinen eigenen Müll weg. Wer viele Prozesse startet, produziert Waisen. Ein kleiner Wächter, der scannt, wartet und killt, ist die pragmatische Antwort auf ein selbst erzeugtes Problem.
  2. Eine gute Heuristik schlägt eine RAM-Schwelle. PPID 1 sagt verlässlich, was tot ist. Nach Speicherverbrauch zu killen würde die falschen Prozesse treffen. Die Gnadenfrist fängt den Rest der Fehlalarme ab.
  3. Nicht jedes Werkzeug muss ein Produkt werden. DevWatchdog ist ein persönliches Experiment im Regal, offen und ehrlich so eingeordnet. Ein enges Tool für den eigenen Bedarf ist ein völlig legitimes Ergebnis.

Wenn du ähnlich viele Sessions parallel fährst und deine Maschine regelmäßig ins Swappen kippt, lohnt vielleicht ein eigener kleiner Wächter, oder ein diszipliniertes pkill im richtigen Moment. Wenn du so etwas lieber sauber in deine eigene Arbeitsweise eingebaut hättest, führt der direkte Weg über den Kontakt.