Skip to content

desktop: A log-write failure wedges the trainer at "running" forever and blocks every future run #4493

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 4 of 16.

Breaks: one OSError on the log write abandons the reader while the child is still writing to a PIPE nobody drains. The child blocks on the full pipe, proc.wait() blocks on the child, run.finish() never runs — the run reads running permanently and Trainer.start refuses every subsequent run for the life of the engine. One [reader stopped: …] line scrolls past and nothing else is said.
Who / likelihood: a full disk, a revoked directory, a network home — uncommon, but the failure is permanent and needs a restart the user has no reason to connect to it. Reproduced with r5.py (child would print ~1 MB and exit in under a second; first log write ENOSPC): +6s state=running child alive=True events=3.
Where: engine/training.py:355-361.

--- a/engine/training.py
+++ b/engine/training.py
@@ -353,11 +353,29 @@ class Trainer:
-        try:
-            with open(run.log_path, "a", encoding="utf-8") as log:
-                for line in proc.stdout:
-                    log.write(line)
-                    self._consume(run, line.rstrip("\n"))
-        except Exception as exc:                       # noqa: BLE001
-            run.emit("log", {"line": f"[reader stopped: {exc}]"})
+        try:
+            log = open(run.log_path, "a", encoding="utf-8")
+        except OSError as exc:
+            log = None
+            run.emit("log", {"line": f"[log unavailable: {exc}]"})
+        try:
+            for line in proc.stdout:
+                # The pipe is drained whatever happens to the log. Stopping
+                # the loop leaves the child blocked on a full pipe and
+                # proc.wait() blocked on the child, so the run never ends
+                # and every later run is refused.
+                if log is not None:
+                    try:
+                        log.write(line)
+                        log.flush()   # the log is the full record only if it exists
+                    except OSError as exc:
+                        run.emit("log", {"line": f"[log stopped: {exc}]"})
+                        log.close()
+                        log = None
+                try:
+                    self._consume(run, line.rstrip("\n"))
+                except Exception as exc:               # noqa: BLE001
+                    run.emit("log", {"line": f"[reader error: {exc}]"})
+        finally:
+            if log is not None:
+                log.close()
         code = proc.wait()

The flush() also fixes the separate observation that train.log is block-buffered and never flushed — 0 bytes on disk throughout a live run, and 0 bytes after a kill, while training.py:37 and :156 cite "the log file is the full record" as the reason for capping in-memory events at 4000.

Test — engine/test_training.py: stub trainer prints ~200 KB then exits 0; patch the log open (or point log_path at a directory) so writing fails; assert within a few seconds that run.state == DONE and train.log's absence did not stop metrics/events from growing past the pre-failure count. Today the state stays running indefinitely.



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