fix(launcher): never leave the server JVM behind after the client is gone #87

Merged
hauschel.fred merged 2 commits from fix/80-orphan-jvm into main 2026-09-07 16:59:31 +00:00
Collaborator

Closes #80

Ursache (an einer lebenden Waise verifiziert)

Die JVM lief in derselben Prozessgruppe wie der Client. Ein gruppenweites Stop-Signal an den Client (SIGTSTP/Ctrl-Z, SIGTTOU) friert die JVM mit ein (Zustand T). Eine gestoppte JVM reagiert weder auf stdin-EOF noch auf SIGTERM; stirbt der Wrapper, wird sie reparented und lebt ewig. Befund auf dem Entwicklungsrechner heute: claude selbst im Zustand Tl, JVM in dessen Prozessgruppe, Wrapper als Zombie, JVM ohne Terminal (alle stdio-FDs Sockets). Die SIGTTOU-über-stderr-Hypothese aus dem Issue ist damit widerlegt.

Fix, drei unabhängige Ebenen

  1. Launcher: JVM als Hintergrund-Job in eigener Prozessgruppe (set -m); Wrapper leitet TERM/INT/HUP als CONT + Signal weiter und wartet weiter, damit der echte Exit-Code für die Recovery bleibt. Recovery feuert nicht mehr bei Client-initiiertem Ende (Signal oder Exit 0). Vorher hat ein Client, der innerhalb von 30 s wieder abbaute, den Workspace gelöscht und den Server ohne Client neu gestartet.
  2. ParentProcessWatchdog: JVM pollt ihren Parent-PID (2 s) und fährt herunter, sobald sie reparented wurde (Wrapper SIGKILLed oder abgestürzt).
  3. ExitDeadline: Nach Shutdown-Entscheidung hält die JVM nach 60 s hart an, falls der geordnete Pfad hängt. IApplication.stop() stoppt jetzt auch den stdio-Transport, sonst wartet der Framework-Shutdown auf stdin.

Tests

Neu: tests/lifecycle-test.sh (stdin-EOF, SIGTERM an Wrapper, gestoppte JVM + SIGTERM, SIGKILL an Wrapper, eigene Prozessgruppe), in CI eingehängt.

Binary Ergebnis
0.2.18 (installiert) 0/5
dieser Branch 5/5

Smoke-Tests 9/9. Nebenbei gefixt: fail() im Test-Helper brach unter set -e beim ersten FAIL ohne Detail den ganzen Lauf ab.

Nicht angefasst: jdtls-mcp.cmd (Windows) – dort greifen nur Ebene 2 und 3.

🤖 Generated with Claude Code

https://claude.ai/code/session_0132uJuiDFWGyUk9YCzFPzVK

Closes #80 ## Ursache (an einer lebenden Waise verifiziert) Die JVM lief in derselben Prozessgruppe wie der Client. Ein gruppenweites Stop-Signal an den Client (SIGTSTP/Ctrl-Z, SIGTTOU) friert die JVM mit ein (Zustand `T`). Eine gestoppte JVM reagiert weder auf stdin-EOF noch auf SIGTERM; stirbt der Wrapper, wird sie reparented und lebt ewig. Befund auf dem Entwicklungsrechner heute: `claude` selbst im Zustand `Tl`, JVM in dessen Prozessgruppe, Wrapper als Zombie, JVM ohne Terminal (alle stdio-FDs Sockets). Die SIGTTOU-über-stderr-Hypothese aus dem Issue ist damit widerlegt. ## Fix, drei unabhängige Ebenen 1. **Launcher:** JVM als Hintergrund-Job in eigener Prozessgruppe (`set -m`); Wrapper leitet TERM/INT/HUP als `CONT` + Signal weiter und wartet weiter, damit der echte Exit-Code für die Recovery bleibt. Recovery feuert nicht mehr bei Client-initiiertem Ende (Signal oder Exit 0). Vorher hat ein Client, der innerhalb von 30 s wieder abbaute, den Workspace gelöscht und den Server ohne Client neu gestartet. 2. **`ParentProcessWatchdog`:** JVM pollt ihren Parent-PID (2 s) und fährt herunter, sobald sie reparented wurde (Wrapper SIGKILLed oder abgestürzt). 3. **`ExitDeadline`:** Nach Shutdown-Entscheidung hält die JVM nach 60 s hart an, falls der geordnete Pfad hängt. `IApplication.stop()` stoppt jetzt auch den stdio-Transport, sonst wartet der Framework-Shutdown auf stdin. ## Tests Neu: `tests/lifecycle-test.sh` (stdin-EOF, SIGTERM an Wrapper, gestoppte JVM + SIGTERM, SIGKILL an Wrapper, eigene Prozessgruppe), in CI eingehängt. | Binary | Ergebnis | |---|---| | 0.2.18 (installiert) | 0/5 | | dieser Branch | 5/5 | Smoke-Tests 9/9. Nebenbei gefixt: `fail()` im Test-Helper brach unter `set -e` beim ersten FAIL ohne Detail den ganzen Lauf ab. Nicht angefasst: `jdtls-mcp.cmd` (Windows) – dort greifen nur Ebene 2 und 3. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0132uJuiDFWGyUk9YCzFPzVK
`[ -n "$detail" ] && echo` returns 1 when detail is empty, so the first
fail() call terminated the whole test script before the summary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132uJuiDFWGyUk9YCzFPzVK
Root cause (verified on a live orphan, see #80): the JVM shared the client's
process group, so a group-wide stop signal aimed at the client (SIGTSTP,
SIGTTOU) froze it in state T. A stopped JVM cannot react to stdin EOF or
SIGTERM, and once the wrapper died it was reparented and lived on forever.
The SIGTTOU-via-stderr hypothesis is refuted: the JVM has no controlling
terminal, all stdio fds are sockets.

Three independent layers, each verified by tests/lifecycle-test.sh:

- Launcher: JVM runs as a background job in its own process group (set -m),
  the wrapper forwards TERM/INT/HUP as CONT+signal and keeps waiting so the
  real exit code is available for workspace recovery. Recovery no longer
  fires on a client-initiated shutdown (signal or clean exit 0) — before,
  a client disconnecting within 30 s deleted the workspace and restarted
  the server without a client.
- ParentProcessWatchdog: the JVM polls its parent PID and shuts down when
  it has been reparented (wrapper SIGKILLed or crashed).
- ExitDeadline: once shutdown has been decided, the JVM halts after 60 s
  if the orderly path hangs. IApplication.stop() now also stops the stdio
  transport so framework shutdown does not wait for stdin.

Lifecycle tests: 0/5 pass on 0.2.18, 5/5 with this change. Wired into CI.

Closes #80

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132uJuiDFWGyUk9YCzFPzVK
hauschel.fred deleted branch fix/80-orphan-jvm 2026-09-07 16:59:31 +00:00
Commenting is not possible because the repository is archived.
No description provided.