Auf der Jagd nach einem 250-%-CPU-Bug in einem Vite-Monorepo

ViteMonorepoPerformanceDebuggingAgentic Coding

Die Lüfter meines MacBook Pro drehen selten auf. Als sie es an einem ruhigen Nachmittag taten, ohne dass offensichtlich etwas lief, wusste ich, dass etwas nicht stimmte. Der Activity Monitor zeigte drei Node-Prozesse, jeder bei 236–250 % CPU festgenagelt — allesamt Vite-Dev-Server aus meinem SaaS-Monorepo.

Die naheliegende Antwort wäre gewesen: „Vite ist halt so, es ist ein großes Projekt." Die tatsächliche Antwort entpuppte sich als eine vierzeilige Konfigurationsänderung und eine einzeilige Hook-Interaktion, die ich ohne methodisches Sammeln von Evidenz nie entdeckt hätte.

Das hier ist eine Geschichte darüber, dem Drang zu raten zu widerstehen — und darüber, wie agentisches Debugging tatsächlich aussieht, wenn das Ziel ein Performance-Problem ist und kein Absturz.

Der Aufbau

Das Monorepo betreibt vier Vite-Dev-Server parallel über devenv up: einen Legacy-Management-Client, einen neu geschriebenen Management-Client, eine PWA für Endnutzer und eine Admin-Konsole. Jede App ist ein eigenes Projekt in ihrem eigenen Workspace, jede importiert aus einer Handvoll geteilter Pakete (@hausify/ui, @hausify/api-client, @hausify/utils), allesamt über pnpm-Symlinks verlinkt.

Alle vier gleichzeitig laufen zu lassen war schon immer etwas schwergewichtig — es sind nun einmal vier vollwertige Dev-Server. Aber das hier war anders. Die Lüfter waren hörbar. Der Load Average des Systems erreichte 14 auf einer 8-Kern-Maschine. Irgendetwas war wirklich kaputt.

Dem naheliegenden „Fix" widerstehen

Der erste Reflex ist, anzufangen, Dinge zu ändern. Plugins deaktivieren. Auf Polling umstellen. optimizeDeps.exclude ergänzen. fsevents neu installieren. Jede dieser Maßnahmen könnte helfen. Jede davon ist aber auch ein Schuss ins Blaue.

Ich habe eine Regel fürs Debugging: keine Fixes vor der Root Cause. Ein Rateschuss, der zufällig funktioniert, lehrt dich nichts. Ein Rateschuss, der nicht funktioniert, kostet Zeit und fügt der Codebasis Komplexität hinzu. Die Kosten, zuerst Evidenz zu sammeln, sind fast immer geringer als die Kosten von drei falschen Rateschüssen in Folge.

Der erste Schritt war also kein Fix. Es war eine Messung.

Evidenz sammeln

ps -axo pid,pcpu,pmem,command | grep vite

Drei Vite-Prozesse bei kumuliert 240 % CPU. Einer — der Admin — bei 0 %. Diese eine Asymmetrie war der wichtigste Hinweis der gesamten Untersuchung. Der Admin läuft mit derselben Vite-Version, denselben Plugins, derselben geteilten Konfiguration. Wäre es ein Vite-Problem, würden sich alle vier danebenbenehmen. Irgendetwas an den anderen drei war anders.

Als Nächstes: File Descriptors. Unter macOS sollte ein Vite-Dev-Server, der fsevents korrekt nutzt, ein oder zwei native Watcher-Descriptoren für das Projektwurzelverzeichnis halten. Wenn er stattdessen Verzeichnis für Verzeichnis pollt, sieht man hunderte DIR- und KQUEUE-Descriptoren.

lsof -p <pid> | awk '{print $5}' | sort | uniq -c

Die heißen Vite-Prozesse hatten jeweils 253 DIR-Descriptoren und 93 KQUEUE-Descriptoren. Der Admin hatte 38 und 9. Das war ein Faktor-6-Unterschied in der Watcher-Oberfläche — ein starker Hinweis darauf, dass die heißen Prozesse weit mehr Dateisystemarbeit leisteten, entweder weil sie mehr beobachteten oder weil sich ständig etwas unter ihnen veränderte.

Zur Bestätigung nahm ich ein Profiler-Sample:

sample <pid> 3

Vierundfünfzig Prozent des Main-Threads steckten in node::fs::AfterStat — dem Callback, der feuert, nachdem ein asynchrones lstat() abgeschlossen ist. Weitere zwölf Prozent in fs::LStat selbst. Der Prozess kompilierte nicht, bundelte nicht, beantwortete keine Requests. Er erzeugte einen kontinuierlichen Sturm an Dateisystem-Stat-Aufrufen.

In diesem Moment fügte sich das Bild zusammen.

Die Root Cause

Vites File-Watcher ruft stat() auf Dateien auf, wenn er Events empfängt — das ist normal. Was nicht normal ist: stat zehntausende Male pro Minute auf einem im Leerlauf befindlichen Dev-Server aufzurufen. Das passiert nur, wenn sich ständig etwas auf der Platte ändert und dadurch ständig Events erzeugt.

Und es änderte sich tatsächlich ständig etwas auf der Platte.

Meine Entwicklungsumgebung hat einen Post-Edit-Hook, der mvn compile ausführt, sobald eine Java-Quelldatei gespeichert wird. Maven schreibt kompilierte .class-Dateien nach api/target/classes/. Die Vite-Dev-Server waren so konfiguriert, dass sie nur **/node_modules/** und **/.git/** ignorierten — was bedeutete, dass api/target/ mitbeobachtet wurde. Jeder Java-Recompile löste eine Lawine an Datei-Events aus, und jedes Event triggerte einen lstat-Aufruf in Vite.

Der Legacy-Admin-Client war nicht betroffen, weil er weniger Dateien in seinem Quellbaum hat und nicht aus den großen geteilten Paketen importiert — sein Watcher-Graph war also klein genug, dass das Rauschen nicht dominierte.

Es gab ein zweites, verwandtes Problem: Keine der Vite-Konfigurationen hatte einen optimizeDeps.include-Eintrag für die Workspace-Pakete. In einem pnpm-Monorepo werden Workspace-Pakete per Symlink eingebunden statt als CommonJS-Bundles installiert. Vite behandelt sie standardmäßig als Quellcode — großartig für HMR bei aktiv entwickelten Paketen, aber für stabilen generierten Code wie einen API-Client bedeutet es, dass der Dev-Server bei jedem Kaltstart in den verlinkten Quellcode hineinkriecht und wiederholt Auflösungskosten zahlt.

Der Fix

Zwei Änderungen, in einer geteilten Konfigurationsdatei:

export const SHARED_WATCH_IGNORED = [
  "**/node_modules/**",
  "**/.git/**",
  "**/build/**",
  "**/dist/**",
  "**/api/target/**",        // der Knackpunkt
  "**/.devenv/**",
  "**/.pulumi/**",
  "**/e2e/test-results/**",
  "**/e2e/playwright-report/**",
];
 
export const SHARED_OPTIMIZE_DEPS_INCLUDE = [
  "@hausify/api-client/client.gen",
  "@hausify/api-client/sdk.gen",
  "@hausify/api-client/@tanstack/react-query.gen",
  "@hausify/utils/image-compression",
];

Jede der vier Vite-Konfigurationen importiert diese Konstanten und verdrahtet sie in server.watch.ignored und optimizeDeps.include. Entscheidend: @hausify/ui ist nicht in der Prebundle-Liste — es ist in aktiver Entwicklung, und ich will HMR darauf. Die Prebundle-Liste deckt nur Pakete ab, die sich während einer Coding-Session selten ändern.

Den Fix verifizieren

Das ist das Entscheidende am Debugging: Der Fix ist nicht fertig, wenn er zu funktionieren scheint. Er ist fertig, wenn die Messungen ihn bestätigen.

Metrik (ein Vite-Dev-Server)VorherNachherDelta
Kumulierte durchschnittliche CPU~240 %0,0 %
CPU-Zeit / Wall-Time~50 %~1,5 %~30× geringer
DIR-File-Descriptoren25360-76 %
KQUEUE-File-Descriptoren9313-86 %
fs::AfterStat-Samples in einem 2-s-Profil~11500eliminiert

Der systm-File-Descriptor — der eine native fsevents-Watcher — tauchte im gesunden Zustand wieder auf. fsevents war nie kaputt. Es kam nur nicht mit der Menge an Events hinterher, die von Dateien erzeugt wurden, die von vornherein gar nicht hätten beobachtet werden dürfen.

Systemweit: Der Load Average fiel von 14 auf unter 3. Die Lüfter wurden still. HMR funktionierte weiterhin über alle vier Server hinweg.

Die Lehren

Die Asymmetrie ist der Hinweis. Vier nahezu identische Vite-Server, einer verhält sich korrekt. Die Frage lautet nicht „Was stimmt nicht mit Vite", sondern „Was ist an den drei heißen anders?". Asymmetrien in Systemen, die symmetrisch sein sollten, zeigen direkt auf die Ursache.

Profilen, bevor du fixst. sample unter macOS (oder perf unter Linux) braucht Sekunden und sagt dir, wohin die Zeit tatsächlich fließt. „Wahrscheinlich ist es das Plugin" ist keine Evidenz. Ein Call-Graph mit fs::AfterStat bei 54 % ist Evidenz.

Watcher haben einen Wirkungsradius. Die ignored-Liste eines File-Watchers ist keine Bequemlichkeit — sie ist eine Korrektheitseigenschaft. Wenn irgendetwas in deiner Build-Pipeline in den beobachteten Baum schreibt (kompilierte Ausgaben, generierter Code, Cache-Dateien), verbrennt der Watcher CPU, bis man ihm sagt, er soll aufhören.

Monorepo-Symlinks haben Kosten. pnpm-Workspaces sind elegant, aber jede Vite-Instanz entdeckt sie eigenständig und zahlt ihre eigene Auflösungsrechnung. optimizeDeps.include für stabile Workspace-Pakete ist der günstige Fix.

Agentisches Debugging funktioniert. Die gesamte Untersuchung — von „die Lüfter sind laut" bis „gemergter Fix" — dauerte etwa dreißig Minuten Hin und Her. Nicht, weil der Agent gut geraten hätte, sondern weil der Agent ps, lsof, sample ausführte, die Ausgaben las, Hypothesen bildete und sie gegen neue Messungen testete. Der menschliche Beitrag bestand größtenteils aus „ja, das ist die richtige nächste Frage". Die Maschinerie des systematischen Debuggings skaliert wunderbar, wenn da etwas ist, das willig zwölf Shell-Befehle hintereinander durcharbeitet, ohne den Faden zu verlieren.

Wenn dein Dev-Server heiß läuft und du nicht weißt, warum: Fang mit ps und sample an. Die Antwort versteckt sich fast immer im Dateisystem.

Alle Artikel