Verwaiste JVM nach Session-Ende: Wrapper stirbt, Java-Kind überlebt eingefroren mit ~1 GB #80

Closed
opened 2026-07-15 10:23:00 +00:00 by hauschel.fred · 2 comments
Collaborator

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; ein kill -TERM bleibt wirkungslos. Erst kill -CONT gefolgt von kill -TERM hat sie beendet.

pid=126052  ppid=1394  rss=1105MB  alter=25h  state=T (stopped)
pid=304996  ppid=1394  rss=1020MB  alter=21h  state=T (stopped)
pid=409490  ppid=1394  rss=715MB   alter=19h  state=T (stopped)
pid=193161  ppid=1394  rss=516MB   alter=23h  state=T (stopped)
pid=252078  ppid=1394  rss=444MB   alter=22h  state=T (stopped)

ppid=1394 ist 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-mcp startet die JVM ohne exec:

run_server() {
    "$JAVA" -Djdtmcp.headless=true ... -jar "$LAUNCHER" ... "$@"
}

START_TIME=$(date +%s)
run_server "$@"          # <-- Zeile 147: Kind, kein exec
EXIT_CODE=$?

Das ist Absicht — der Wrapper braucht $EXIT_CODE und $ELAPSED fü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:

  1. SIGTTOU — die JVM schreibt nach stderr; ist stderr das Terminal, die Prozessgruppe verwaist und tostop gesetzt, wird der Prozess gestoppt. Das würde zu #54 passen (34 Stellen System.err.println).
  2. SIGTTIN — Lesen vom Terminal aus einer verwaisten Prozessgruppe.

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.in EOF (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:

run_server() {
    "$JAVA" ... "$@" &
    JAVA_PID=$!
    trap 'kill -TERM "$JAVA_PID" 2>/dev/null' TERM INT HUP EXIT
    wait "$JAVA_PID"
}

So bleiben $EXIT_CODE/$ELAPSED verfü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 -Xmx im Produkt). #63 begrenzt, wie groß eine laufende JVM werden darf; dieses Issue betrifft, wie lange eine nicht mehr benutzte JVM überhaupt existiert. Ein -Xmx würde die Waisen kleiner machen, aber nicht beseitigen.

Umgebung

  • TUXEDO OS, Kernel 6.17.0-119029-tuxedo, 62 GB RAM, kein Swap konfiguriert (verschärft die Folgen: der Kernel killt sofort, statt auszulagern)
  • Launcher: ~/.local/share/jdtls-mcp/bin/jdtls-mcp, Transport stdio
  • Client: Claude Code
## 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; ein `kill -TERM` bleibt wirkungslos. Erst `kill -CONT` gefolgt von `kill -TERM` hat sie beendet. ``` pid=126052 ppid=1394 rss=1105MB alter=25h state=T (stopped) pid=304996 ppid=1394 rss=1020MB alter=21h state=T (stopped) pid=409490 ppid=1394 rss=715MB alter=19h state=T (stopped) pid=193161 ppid=1394 rss=516MB alter=23h state=T (stopped) pid=252078 ppid=1394 rss=444MB alter=22h state=T (stopped) ``` `ppid=1394` ist 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-mcp` startet die JVM **ohne `exec`**: ```bash run_server() { "$JAVA" -Djdtmcp.headless=true ... -jar "$LAUNCHER" ... "$@" } START_TIME=$(date +%s) run_server "$@" # <-- Zeile 147: Kind, kein exec EXIT_CODE=$? ``` Das ist Absicht — der Wrapper braucht `$EXIT_CODE` und `$ELAPSED` fü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: 1. **SIGTTOU** — die JVM schreibt nach stderr; ist stderr das Terminal, die Prozessgruppe verwaist und `tostop` gesetzt, wird der Prozess gestoppt. Das würde zu #54 passen (34 Stellen `System.err.println`). 2. **SIGTTIN** — Lesen vom Terminal aus einer verwaisten Prozessgruppe. 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.in` EOF (`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: ```bash run_server() { "$JAVA" ... "$@" & JAVA_PID=$! trap 'kill -TERM "$JAVA_PID" 2>/dev/null' TERM INT HUP EXIT wait "$JAVA_PID" } ``` So bleiben `$EXIT_CODE`/`$ELAPSED` verfü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 `-Xmx` im Produkt). #63 begrenzt, **wie groß** eine laufende JVM werden darf; dieses Issue betrifft, **wie lange** eine nicht mehr benutzte JVM überhaupt existiert. Ein `-Xmx` würde die Waisen kleiner machen, aber nicht beseitigen. ## Umgebung - TUXEDO OS, Kernel 6.17.0-119029-tuxedo, 62 GB RAM, **kein Swap konfiguriert** (verschärft die Folgen: der Kernel killt sofort, statt auszulagern) - Launcher: `~/.local/share/jdtls-mcp/bin/jdtls-mcp`, Transport stdio - Client: Claude Code
Author
Collaborator

Triage 2026-09-07: P2 → P1, nächstes Arbeitspaket

Stand auf main geprüft: Weder bin/jdtls-mcp noch McpStdioServer haben 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:

  1. McpStdioServer: Reader-Loop beendet bei read() == -1 den Server geordnet (System.exit nach Workspace-Save).
  2. Launcher: trap auf SIGTERM/SIGHUP, der die JVM-PID mit kill -CONT + kill -TERM beendet, bevor die Recovery-Logik den Exit-Code auswertet.

Die SIGTTOU-Hypothese verweist auf #54 (36 Stellen System.err.println auf main).

## Triage 2026-09-07: P2 → P1, nächstes Arbeitspaket Stand auf `main` geprüft: Weder `bin/jdtls-mcp` noch `McpStdioServer` haben 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: 1. `McpStdioServer`: Reader-Loop beendet bei `read() == -1` den Server geordnet (`System.exit` nach Workspace-Save). 2. Launcher: `trap` auf SIGTERM/SIGHUP, der die JVM-PID mit `kill -CONT` + `kill -TERM` beendet, bevor die Recovery-Logik den Exit-Code auswertet. Die SIGTTOU-Hypothese verweist auf #54 (36 Stellen `System.err.println` auf `main`).
Author
Collaborator

Befund 2026-09-07 an einer lebenden Waise

PID     PPID  PGID    SID     STAT  TT  CMD
1330    1     1330    1330    Ss    ?   systemd --user
632356  1330  632356  632151  Tl    ?   claude
632624  1330  632356  632151  Tl    ?   java … jdtmcp.headless   (780 MB, wchan do_signal_stop)
632609  632356                Z         [jdtls-mcp] <defunct>
  • Der Client selbst (claude) ist gestoppt, die JVM sitzt in seiner Prozessgruppe. Der Stop ist also ein gruppenweites Ereignis (SIGTSTP/SIGTTOU an die Gruppe), kein JVM-spezifisches.
  • SIGTTOU-über-stderr ist widerlegt: die JVM hat kein Controlling-Terminal (TT ?), stdin/stdout/stderr sind Unix-Sockets zu claude.
  • Der Wrapper ist Zombie, weil sein Parent (claude) gestoppt ist und nicht reapen kann. Keine Signale pending (SigPnd 0), d.h. die JVM hat nie ein SIGHUP/SIGTERM gesehen.
  • Plausibelster Auslöser (nicht beweisbar, Prozess ist eingefroren): claude setzt 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 mit CONT im Launcher, ParentProcessWatchdog und ExitDeadline in der JVM, Recovery-Guard. Lifecycle-Tests 0/5 → 5/5.

Aufräumen der aktuellen Waise: kill -CONT 632356 632624claude beendet sich dann, schließt die Sockets, die JVM sieht EOF und geht.

## Befund 2026-09-07 an einer lebenden Waise ``` PID PPID PGID SID STAT TT CMD 1330 1 1330 1330 Ss ? systemd --user 632356 1330 632356 632151 Tl ? claude 632624 1330 632356 632151 Tl ? java … jdtmcp.headless (780 MB, wchan do_signal_stop) 632609 632356 Z [jdtls-mcp] <defunct> ``` - **Der Client selbst (`claude`) ist gestoppt**, die JVM sitzt in seiner Prozessgruppe. Der Stop ist also ein gruppenweites Ereignis (SIGTSTP/SIGTTOU an die Gruppe), kein JVM-spezifisches. - **SIGTTOU-über-stderr ist widerlegt:** die JVM hat kein Controlling-Terminal (`TT ?`), stdin/stdout/stderr sind Unix-Sockets zu `claude`. - Der Wrapper ist Zombie, weil sein Parent (`claude`) gestoppt ist und nicht reapen kann. Keine Signale pending (`SigPnd 0`), d.h. die JVM hat nie ein SIGHUP/SIGTERM gesehen. - Plausibelster Auslöser (nicht beweisbar, Prozess ist eingefroren): `claude` setzt 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 mit `CONT` im Launcher, `ParentProcessWatchdog` und `ExitDeadline` in der JVM, Recovery-Guard. Lifecycle-Tests 0/5 → 5/5. **Aufräumen der aktuellen Waise:** `kill -CONT 632356 632624` – `claude` beendet sich dann, schließt die Sockets, die JVM sieht EOF und geht.
Commenting is not possible because the repository is archived.
No project
No assignees
1 participant
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
ai-tools/jdt-mcp-server#80
No description provided.