Verwaiste JVM nach Session-Ende: Wrapper stirbt, Java-Kind überlebt eingefroren mit ~1 GB #80
Labels
No labels
bug
build
enhancement
headless
P1-critical
P2-high
P3-medium
P4-low
refactoring
No milestone
No project
No assignees
1 participant
Due date
No due date set.
Dependencies
No dependencies set.
Reference
ai-tools/jdt-mcp-server#80
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Symptom
Nach dem Ende einer Client-Session (Claude Code) bleibt der JDT-MCP-JVM-Prozess dauerhaft am Leben, ohne noch benutzbar zu sein. Er belegt 0,4–1,1 GB RSS und wird nie beendet. Pro toter Session sammelt sich eine weitere solche Waise an.
Auf einem Entwicklungsrechner (62 GB RAM) haben sich so fünf Waisen mit zusammen ~3,8 GB angesammelt, die älteste 25 Stunden alt. Sie waren mitverantwortlich für einen globalen OOM-Kill am 2026-07-15 12:14:42, bei dem der Kernel einen anderen Prozess (20 GB) abgeschossen hat. Die Waisen waren nicht die Hauptursache dieses OOM, aber sie verkleinern den verfügbaren Speicher dauerhaft und ohne jeden Gegenwert.
Beobachtung
Alle fünf verwaisten JVMs standen im Prozess-Zustand
T(stopped) — sie waren nicht nur nutzlos, sondern eingefroren. Ein gestoppter Prozess verarbeitet SIGTERM nicht; einkill -TERMbleibt wirkungslos. Erstkill -CONTgefolgt vonkill -TERMhat sie beendet.ppid=1394ist der systemd-User-Manager, das heißt die JVMs wurden reparented — ihr ursprünglicher Parent ist weg.Parallel dazu existierten fünf Zombie-Wrapper (
[jdtls-mcp] <defunct>) mit PIDs 126035, 193139, 252060, 304980, 409473. Die PIDs korrespondieren paarweise mit den JVM-Waisen (126035↔126052, 193139↔193161, 252060↔252078, 304980↔304996, 409473↔409490) — jeder Zombie ist der tote Wrapper genau einer Waise.Ursache
Der Launcher
bin/jdtls-mcpstartet die JVM ohneexec:Das ist Absicht — der Wrapper braucht
$EXIT_CODEund$ELAPSEDfür die Workspace-Recovery-Logik (Zeilen 152–171). Die Folge ist aber eine Prozesskette aus zwei Prozessen:bash(Wrapper) →java(JVM).Beendet der Client die Session, stirbt nur der direkte Kindprozess — der bash-Wrapper. Die JVM darunter erfährt davon nichts, wird zu systemd reparented und läuft weiter. Sie erkennt weder das stdin-EOF noch den Tod ihres Parents und terminiert deshalb nie von selbst.
Offen: warum
T (stopped)und nicht einfach „läuft weiter"? Das habe ich nicht abschließend verifiziert, die Prozesse sind inzwischen beendet. Zwei plausible Mechanismen:tostopgesetzt, wird der Prozess gestoppt. Das würde zu #54 passen (34 StellenSystem.err.println).Für die Behebung ist die genaue Antwort zweitrangig: Eine JVM, die den Parent-Tod bemerkt und sich beendet, kommt gar nicht erst in diesen Zustand.
Lösungsvorschlag
Zwei Ebenen, unabhängig voneinander wirksam:
1. JVM-seitig — Parent-Death-Erkennung (die eigentliche Absicherung). Im stdio-Transport einen Watchdog: Liefert
System.inEOF (read() == -1), ist der Client weg — dann geordnet herunterfahren statt weiterzulaufen. Das ist das übliche Lifecycle-Kontrakt für stdio-MCP-Server und wirkt unabhängig davon, wie der Prozess gestartet wurde.2. Wrapper-seitig — Signal-Weiterleitung. Die Recovery-Logik verhindert ein simples
exec, aber der Wrapper kann die JVM im Hintergrund starten, ihre PID merken und Signale weiterreichen:So bleiben
$EXIT_CODE/$ELAPSEDverfügbar, und stirbt der Wrapper, nimmt er die JVM mit.Ergänzend denkbar: die JVM per
prctl(PR_SET_PDEATHSIG)-Äquivalent bzw. über systemd-Scope-Zugehörigkeit binden — beides deutlich aufwendiger als die zwei Punkte oben.Abgrenzung
Nicht zu verwechseln mit #63 (kein
-Xmxim Produkt). #63 begrenzt, wie groß eine laufende JVM werden darf; dieses Issue betrifft, wie lange eine nicht mehr benutzte JVM überhaupt existiert. Ein-Xmxwürde die Waisen kleiner machen, aber nicht beseitigen.Umgebung
~/.local/share/jdtls-mcp/bin/jdtls-mcp, Transport stdioTriage 2026-09-07: P2 → P1, nächstes Arbeitspaket
Stand auf
maingeprüft: Wederbin/jdtls-mcpnochMcpStdioServerhaben eine Parent-Death- oder stdin-EOF-Erkennung. Das Problem besteht unverändert, tritt pro beendeter Session auf und hat einen OOM-Kill mitverursacht. Das ist kritischer als die Race Conditions in #48, die nur beim Reload greifen.Umsetzung wie im Lösungsvorschlag, beide Ebenen:
McpStdioServer: Reader-Loop beendet beiread() == -1den Server geordnet (System.exitnach Workspace-Save).trapauf SIGTERM/SIGHUP, der die JVM-PID mitkill -CONT+kill -TERMbeendet, bevor die Recovery-Logik den Exit-Code auswertet.Die SIGTTOU-Hypothese verweist auf #54 (36 Stellen
System.err.printlnaufmain).Befund 2026-09-07 an einer lebenden Waise
claude) ist gestoppt, die JVM sitzt in seiner Prozessgruppe. Der Stop ist also ein gruppenweites Ereignis (SIGTSTP/SIGTTOU an die Gruppe), kein JVM-spezifisches.TT ?), stdin/stdout/stderr sind Unix-Sockets zuclaude.claude) gestoppt ist und nicht reapen kann. Keine Signale pending (SigPnd 0), d.h. die JVM hat nie ein SIGHUP/SIGTERM gesehen.claudesetzt beim Beenden Terminal-Modi zurück (tcsetattr), während es nicht mehr Vordergrund-Gruppe ist → SIGTTOU an die ganze Gruppe.Der stdin-EOF-Pfad existierte bereits (
readLine() == null→ Latch →start()kehrt zurück) und funktioniert, solange die JVM nicht gestoppt ist – das zeigen die Logs (stdin closed, shutting down) seit März.Zusatzbefund: Die Quick-Exit-Recovery (<30 s) hat bei jedem schnellen Client-Ende den Workspace gelöscht und den Server ohne Client neu gestartet (Lifecycle-Test 1 gegen 0.2.18).
Fix in Branch
fix/80-orphan-jvm(PR offen): eigene Prozessgruppe + Signal-Weiterleitung mitCONTim Launcher,ParentProcessWatchdogundExitDeadlinein der JVM, Recovery-Guard. Lifecycle-Tests 0/5 → 5/5.Aufräumen der aktuellen Waise:
kill -CONT 632356 632624–claudebeendet sich dann, schließt die Sockets, die JVM sieht EOF und geht.