# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 """Parent-side training event-pump resilience. The pump is the only writer of the progress state /progress, /status, /metrics and DB history read. If it died while the worker ran, the run would continue while the UI froze -- the "training runs but no progress shows" symptom. These tests pin two guards: a bad event/queue error can't kill the pump, and a dead pump is detected and restarted (even after worker exit) so terminal events still finalize. Fakes only; no GPU, network, or subprocess. """ from __future__ import annotations import contextlib import logging import queue import sys import threading import time import types as _types from pathlib import Path import pytest _BACKEND_DIR = str(Path(__file__).resolve().parent.parent) if _BACKEND_DIR not in sys.path: sys.path.insert(0, _BACKEND_DIR) # Stub the heavy module-level imports of core/training/training.py so it imports # under CPU-only/no-network, then restore them (see the restore loop below). _SAVED: dict = {} def _stub(name, mod): _SAVED[name] = sys.modules.get(name) sys.modules[name] = mod _lg = _types.ModuleType("loggers") _lg.get_logger = lambda name: logging.getLogger(name) _stub("loggers", _lg) _stub("structlog", _types.ModuleType("structlog")) _mpl = _types.ModuleType("matplotlib") _plt = _types.ModuleType("matplotlib.pyplot") _plt.Figure = type("Figure", (), {}) # referenced in a class-def annotation _mpl.pyplot = _plt _stub("matplotlib", _mpl) _stub("matplotlib.pyplot", _plt) _hw = _types.ModuleType("utils.hardware") _hw.prepare_gpu_selection = lambda *a, **k: (None, None) _stub("utils.hardware", _hw) _npl = _types.ModuleType("utils.native_path_leases") _npl.native_path_secret_removed_for_child_start = lambda: contextlib.nullcontext() _npl.run_without_native_path_secret = lambda fn: fn _stub("utils.native_path_leases", _npl) _pth = _types.ModuleType("utils.paths") _pth.outputs_root = lambda *a, **k: "/tmp/outputs" _stub("utils.paths", _pth) # Whether core.training.training was already imported before this file ran; only # evict it below if we were the one to create the (stub-bound) module instance. _TRAINING_PRE_IMPORTED = "core.training.training" in sys.modules from core.training.training import TrainingBackend # Restore every stubbed module so this file never pollutes the shared session. for _name in ( "loggers", "structlog", "matplotlib", "matplotlib.pyplot", "utils.hardware", "utils.native_path_leases", "utils.paths", ): _prev = _SAVED.get(_name) if _prev is None: sys.modules.pop(_name, None) else: sys.modules[_name] = _prev # training imported its helpers while the stubs were active, binding them to stubs. # If we created the cached module, evict it (and its parent) so a later test # re-imports the real one. if not _TRAINING_PRE_IMPORTED: sys.modules.pop("core.training.training", None) sys.modules.pop("core.training", None) class _FakeProc: """A subprocess handle whose liveness the test drives directly.""" def __init__(self, alive: bool = True): self._alive = alive self.pid = 4321 def is_alive(self): return self._alive def join(self, timeout = None): self._alive = False class _IdleQueue: """get()/get_nowait() always signal "no event" so the pump idles.""" def put(self, *a, **k): pass def get(self, *a, **k): raise queue.Empty def get_nowait(self, *a, **k): raise queue.Empty class _ScriptedQueue: """Yields queued events once, then signals empty forever.""" def __init__(self, events): self._events = list(events) def put(self, *a, **k): pass def get(self, *a, **k): if self._events: return self._events.pop(0) raise queue.Empty def get_nowait(self, *a, **k): if self._events: return self._events.pop(0) raise queue.Empty def _dead_thread() -> threading.Thread: t = threading.Thread(target = lambda: None) t.start() t.join() return t def _silence_db(monkeypatch, b): """Neutralize DB finalization so a started pump exits cleanly off-box.""" monkeypatch.setattr(b, "_ensure_db_run_created", lambda: None) monkeypatch.setattr(b, "_finalize_run_in_db", lambda **k: None) def _wait_until(predicate, timeout = 5.0): deadline = time.time() + timeout while time.time() < deadline: if predicate(): return True time.sleep(0.01) return predicate() # ---------------------------------------------------------------------------- # Guarantee 1: a single bad event/queue error cannot kill the pump. # ---------------------------------------------------------------------------- def test_pump_survives_handler_exception_and_keeps_processing(monkeypatch): b = TrainingBackend() _silence_db(monkeypatch, b) handled: list = [] def fake_handle(ev): if ev.get("type") == "boom": raise RuntimeError("handler blew up") handled.append(ev.get("type")) monkeypatch.setattr(b, "_handle_event", fake_handle) proc = _FakeProc(alive = True) b._proc = proc b._event_queue = _ScriptedQueue( [{"type": "boom"}, {"type": "progress"}, {"type": "boom"}, {"type": "progress"}] ) pump = threading.Thread(target = b._pump_loop, daemon = True) pump.start() try: assert _wait_until( lambda: handled.count("progress") == 2 ), "pump must keep processing good events after handler exceptions" assert pump.is_alive(), "pump thread must survive handler exceptions" assert b._pump_running is True finally: proc._alive = False # let the loop reach its clean exit pump.join(timeout = 5) assert not pump.is_alive() assert b._pump_running is False, "clean exit must clear the running flag" def test_read_queue_narrow_contract(): class _Q: def __init__(self, exc): self.exc = exc def get(self, *a, **k): raise self.exc # Expected closed/broken-queue signals read as "no event". for exc in (queue.Empty(), EOFError(), OSError(), ValueError()): assert TrainingBackend._read_queue(_Q(exc), 0.01) is None # Anything unexpected propagates on purpose to _pump_loop's guarded block, # which logs and backs off instead of swallowing it into a hot loop. with pytest.raises(RuntimeError): TrainingBackend._read_queue(_Q(RuntimeError("boom")), 0.01) def test_pump_survives_queue_read_exception_and_recovers(monkeypatch): # _read_queue raising an unexpected error must be caught by the pump's outer # guard (log + backoff), not kill the pump; once reads recover it processes. b = TrainingBackend() _silence_db(monkeypatch, b) handled: list = [] monkeypatch.setattr(b, "_handle_event", lambda ev: handled.append(ev.get("type"))) class _FlakyQueue: def __init__(self): self.calls = 0 def get(self, *a, **k): self.calls += 1 if self.calls <= 3: raise RuntimeError("transient queue read error") if self.calls == 4: return {"type": "progress", "step": 1} raise queue.Empty def get_nowait(self, *a, **k): raise queue.Empty proc = _FakeProc(alive = True) b._proc = proc b._event_queue = _FlakyQueue() pump = threading.Thread(target = b._pump_loop, daemon = True) pump.start() try: assert _wait_until( lambda: handled == ["progress"] ), "pump must recover after read errors and process the next event" assert pump.is_alive() finally: proc._alive = False pump.join(timeout = 5) def test_pump_finalizes_when_drain_queue_raises_unexpected_error(monkeypatch): # Worker has exited; the final drain hits an unexpected error. The run must # still be finalized (not wedged "active" with a dead worker). b = TrainingBackend() finalized: dict = {} monkeypatch.setattr(b, "_ensure_db_run_created", lambda: None) monkeypatch.setattr(b, "_finalize_run_in_db", lambda **kw: finalized.update(kw)) class _BadDrainQueue: def get(self, *a, **k): raise queue.Empty def get_nowait(self, *a, **k): raise RuntimeError("corrupt drain payload") b._proc = _FakeProc(alive = False) b._event_queue = _BadDrainQueue() b._progress.is_training = True b._pump_loop() # returns once it sees the dead worker assert b._progress.is_training is False assert b._progress.error == "Training process exited unexpectedly" assert finalized.get("status") == "error" assert b._pump_running is False assert b.is_training_active() is False def test_pump_finalizes_when_read_keeps_raising_on_dead_worker(monkeypatch): # An unexpected error escapes _read_queue to the pump's outer guard; if it # keeps raising after worker exit, the loop must still finalize, not spin. b = TrainingBackend() finalized: dict = {} monkeypatch.setattr(b, "_ensure_db_run_created", lambda: None) monkeypatch.setattr(b, "_finalize_run_in_db", lambda **kw: finalized.update(kw)) class _BrokenReadQueue: def get(self, *a, **k): raise RuntimeError("broken queue pipe") def get_nowait(self, *a, **k): raise queue.Empty b._proc = _FakeProc(alive = False) b._event_queue = _BrokenReadQueue() b._progress.is_training = True pump = threading.Thread(target = b._pump_loop, daemon = True) pump.start() pump.join(timeout = 5) assert not pump.is_alive(), "pump must finalize a dead worker even when reads keep raising" assert b._progress.is_training is False assert finalized.get("status") == "error" assert b._pump_running is False def test_start_training_clears_stale_pump_running_flag(): # A prior pump that died abnormally leaves _pump_running True. The next # start_training must clear it during reset so the start-time watchdog can't # treat the fresh setup as a recoverable crash and spawn a duplicate pump. b = TrainingBackend() b._pump_running = True b._pump_thread = None b._proc = None # No model_name -> start_training bails at kwargs["model_name"] (KeyError), # but only AFTER the reset block that clears the stale flag. with pytest.raises(KeyError): b.start_training("job_stale_flag_test") assert b._pump_running is False # ---------------------------------------------------------------------------- # Guarantee 2: a pump that dies while the worker runs is detected + restarted. # ---------------------------------------------------------------------------- def test_ensure_pump_alive_restarts_crashed_pump(monkeypatch): b = TrainingBackend() _silence_db(monkeypatch, b) b._proc = _FakeProc(alive = True) b._event_queue = _IdleQueue() b._pump_running = True # a pump started, then died abnormally dead = _dead_thread() b._pump_thread = dead assert b._ensure_pump_alive() is True try: assert b._pump_thread is not dead assert b._pump_thread.is_alive(), "a fresh pump must be running" finally: b._proc._alive = False b._pump_thread.join(timeout = 5) def test_ensure_pump_alive_noop_when_pump_alive(): b = TrainingBackend() b._proc = _FakeProc(alive = True) b._event_queue = _IdleQueue() b._pump_running = True release = threading.Event() alive = threading.Thread(target = release.wait, daemon = True) alive.start() b._pump_thread = alive try: assert b._ensure_pump_alive() is False assert b._pump_thread is alive finally: release.set() alive.join(timeout = 5) def test_ensure_pump_alive_revives_crashed_pump_after_worker_exit(monkeypatch): # True _pump_running + dead thread = a crash (the loop clears the flag on # intended exits). The queue may still hold terminal events, so the pump must # restart to drain and finalize, else the run is stuck "running" forever. b = TrainingBackend() _silence_db(monkeypatch, b) b._proc = _FakeProc(alive = False) b._event_queue = _IdleQueue() b._progress.is_training = True b._pump_running = True b._pump_thread = _dead_thread() assert b._ensure_pump_alive() is True assert _wait_until( lambda: b._progress.is_training is False ), "the restarted pump must drain + finalize the stranded run" b._pump_thread.join(timeout = 5) assert b._pump_running is False assert b.is_training_active() is False def test_ensure_pump_alive_noop_during_setup(): # _pump_running is False between state-reset and the first pump actually # running; the watchdog must not race in and spawn a rogue pump. b = TrainingBackend() b._proc = _FakeProc(alive = True) b._event_queue = _IdleQueue() b._pump_running = False b._pump_thread = None assert b._ensure_pump_alive() is False assert b._pump_thread is None def test_is_training_active_revives_dead_pump(monkeypatch): b = TrainingBackend() _silence_db(monkeypatch, b) b._proc = _FakeProc(alive = True) b._event_queue = _IdleQueue() b._pump_running = True dead = _dead_thread() b._pump_thread = dead # The status poll the SSE stream makes every second both reports activity # and heals the dead pump as a side effect. assert b.is_training_active() is True try: assert b._pump_thread is not dead assert b._pump_thread.is_alive() finally: b._proc._alive = False b._pump_thread.join(timeout = 5) # ---------------------------------------------------------------------------- # Guarantee 3: the DB run row exists before the pump consumes any event. # ---------------------------------------------------------------------------- def _stub_spawn(monkeypatch): """Stub start_training's spawn surface (GPU pick, mp context, worker).""" g = TrainingBackend.start_training.__globals__ class _SpawnProc: pid = 4321 def start(self): pass def is_alive(self): return True class _Ctx: def Queue(self): return _IdleQueue() def Process(self, **k): return _SpawnProc() # _CTX / prepare_gpu_selection resolve from the module globals; patch the # function's own globals so the eviction of core.training.training (done at # this test module's import for isolation) can't hand us a different copy. monkeypatch.setitem(g, "_CTX", _Ctx()) monkeypatch.setitem(g, "prepare_gpu_selection", lambda *a, **k: (None, None)) hw = _types.ModuleType("utils.hardware") hw.prepare_gpu_selection = lambda *a, **k: (None, None) hw.hardware = type("HW", (), {"DEVICE": "cuda", "DeviceType": type("D", (), {"MLX": "mlx"})})() monkeypatch.setitem(sys.modules, "utils.hardware", hw) pl = _types.ModuleType("utils.process_lifetime") pl.adopt_pid = lambda pid: None monkeypatch.setitem(sys.modules, "utils.process_lifetime", pl) worker = _types.ModuleType("core.training.worker") worker.run_training_process = lambda **k: None monkeypatch.setitem(sys.modules, "core.training.worker", worker) def test_db_run_created_before_pump_consumes_events(monkeypatch): # A fast terminal worker must not race the pump into creating the DB row: by # the time the pump runs, start_training has already created it. The create # sleep widens the window so the ordering is observed, not luck. b = TrainingBackend() _stub_spawn(monkeypatch) def slow_create(): time.sleep(0.05) b._db_run_created = True seen = {} def fake_pump(): seen["db_created"] = b._db_run_created b._pump_running = False monkeypatch.setattr(b, "_ensure_db_run_created", slow_create) monkeypatch.setattr(b, "_pump_loop", fake_pump) assert b.start_training("job_db_order", model_name = "m") is True if b._pump_thread is not None: b._pump_thread.join(timeout = 2.0) # The pump observed an already-created run; it would be False if the pump # were started before the eager create. assert seen["db_created"] is True