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

Open
opened 2026-07-15 10:23:00 +00:00 by hauschel.fred · 0 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
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
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.