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:33 → reclaim::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.
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::shutdowndoes:trainer alive: 63219 ppid 1 pgid 63219, heartbeat still ticking 4s after "quit".Where:
src-tauri/src/supervisor.rs:33→reclaim::kill_pid(self.child.id());src-tauri/src/reclaim.rs:36builds/bin/kill -TERM <pid>, never-<pgid>.detach_group(supervisor.rs:69) and its comment are correct and irrelevant, becauseengine/training.py:324spawns the trainer withstart_new_session=True— a new session, so nokillpgfrom the shell can reach it. The engine's own handler islambda *_: sys.exit(0)(engine/server.py:1998) and its only atexit work isclear_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:
sys.exitin a signal handler raisesSystemExitin the main thread, which unwinds throughserve_foreverinto thefinally;Trainer.stopalready does the correct_terminate_group. NoteEngine::shutdownallows 2s (40 × 50 ms) beforeSIGKILL— 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 whosePRAISONAI_TRAIN_CMDis 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)raisesProcessLookupError). Today it is alive withppid == 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
mainbefore implementing, since the file has moved since.