Skip to content

desktop: Quitting the app orphans the running fine-tune on macOS and Linux #4491

Description

@MervinPraison

Found by a six-lens audit of the desktop app on 27 Aug 2026. Every finding was reproduced against the code, not inferred.
Severity: high · audit rank 2 of 16.

Breaks: the user quits PraisonAI; the trainer keeps the GPU, keeps writing, and is now invisible and unstoppable from the app forever (its parent is init). The process-group machinery written to prevent exactly this is never signalled.
Who / likelihood: every macOS/Linux user who quits during a fine-tune — i.e. the normal way a multi-hour run ends. Reproduced with a real engine plus a stub trainer, killed exactly as Engine::shutdown does: trainer alive: 63219 ppid 1 pgid 63219, heartbeat still ticking 4s after "quit".
Where: src-tauri/src/supervisor.rs:33reclaim::kill_pid(self.child.id()); src-tauri/src/reclaim.rs:36 builds /bin/kill -TERM <pid>, never -<pgid>. detach_group (supervisor.rs:69) and its comment are correct and irrelevant, because engine/training.py:324 spawns the trainer with start_new_session=True — a new session, so no killpg from the shell can reach it. The engine's own handler is lambda *_: sys.exit(0) (engine/server.py:1998) and its only atexit work is clear_lock. Windows is correct (taskkill /T), which is why the comment reads as true.

The fix belongs in the engine, the only process that knows the trainer's group:

--- a/engine/server.py
+++ b/engine/server.py
@@ -1995,7 +1995,17 @@ def main():
     write_lock(port)
     atexit.register(clear_lock)
-    register_exit_signals(lambda *_: sys.exit(0))
+
+    def _stop_everything(*_):
+        # The trainer is in its own session, so no signal aimed at the
+        # engine's process group reaches it. If the engine exits without
+        # stopping it, the run is reparented to init and holds the GPU with
+        # nothing left that can find it.
+        if _TRAINER is not None:
+            try:
+                _TRAINER.stop()
+            except Exception:  # noqa: BLE001 - never block the quit
+                pass
+        sys.exit(0)
+
+    register_exit_signals(_stop_everything)

sys.exit in a signal handler raises SystemExit in the main thread, which unwinds through serve_forever into the finally; Trainer.stop already does the correct _terminate_group. Note Engine::shutdown allows 2s (40 × 50 ms) before SIGKILL — sending one signal is well inside that.

Test — engine/test_train_routes.py (spawns a real engine over loopback, already the right harness): start a run whose PRAISONAI_TRAIN_CMD is a stub that writes its pid to a file and sleeps; os.kill(engine_pid, SIGTERM); wait for the engine to exit; then assert the trainer pid is gone (os.kill(pid, 0) raises ProcessLookupError). Today it is alive with ppid == 1.



Not yet fixed. Filed so it is not lost with the session that found it. The fix and the test above are proposals from the audit — worth re-checking against current main before implementing, since the file has moved since.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingclaudeAuto-trigger Claude analysis

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions