From 00fade756cb4ffa86b0aff95974c9595ec6521a8 Mon Sep 17 00:00:00 2001 From: pixelpaws Date: Thu, 16 Oct 2025 09:02:35 +0800 Subject: [PATCH] fix(settings): dispatch name display updates on original loop --- py/services/model_scanner.py | 8 +++++ py/services/settings_manager.py | 45 +++++++++++++++++++------ tests/services/test_settings_manager.py | 31 +++++++++++++++-- 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/py/services/model_scanner.py b/py/services/model_scanner.py index 5c7389a9..4859f669 100644 --- a/py/services/model_scanner.py +++ b/py/services/model_scanner.py @@ -83,6 +83,12 @@ class ModelScanner: self._excluded_models = [] # List to track excluded models self._persistent_cache = get_persistent_cache() self._name_display_mode = self._resolve_name_display_mode() + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + self._loop = loop + self.loop = loop self._initialized = True # Register this service @@ -104,6 +110,8 @@ class ModelScanner: loop = None if loop and not loop.is_closed(): + self._loop = loop + self.loop = loop loop.create_task(self.initialize_in_background()) def _resolve_name_display_mode(self) -> str: diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 3e0475f0..18b9209f 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -5,7 +5,7 @@ import os import logging from datetime import datetime, timezone from threading import Lock -from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence +from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple from ..utils.constants import DEFAULT_PRIORITY_TAG_CONFIG from ..utils.settings_paths import ensure_settings_file @@ -486,7 +486,13 @@ class SettingsManager: return display_mode = value if isinstance(value, str) else "model_name" - coroutines = [] + pending: List[Tuple[Optional[asyncio.AbstractEventLoop], Awaitable[Any]]] = [] + + def _resolve_service_loop(service: Any) -> Optional[asyncio.AbstractEventLoop]: + loop = getattr(service, "loop", None) + if loop is None: + loop = getattr(service, "_loop", None) + return loop if isinstance(loop, asyncio.AbstractEventLoop) else None for service_name in ( "lora_scanner", @@ -509,23 +515,42 @@ class SettingsManager: continue if asyncio.iscoroutine(result): - coroutines.append(result) + service_loop = _resolve_service_loop(service) + pending.append((service_loop, result)) - if not coroutines: + if not pending: return try: loop = asyncio.get_running_loop() except RuntimeError: - for coroutine in coroutines: + loop = None + + for service_loop, coroutine in pending: + target_loop = service_loop or loop + + if target_loop is None: try: asyncio.run(coroutine) except RuntimeError: - # If event loop is already running in another thread, skip execution - logger.debug("Skipping name display update due to running loop") - else: - for coroutine in coroutines: - loop.create_task(coroutine) + logger.debug("Skipping name display update due to missing event loop") + continue + + if loop is not None and target_loop is loop: + target_loop.create_task(coroutine) + continue + + if target_loop.is_running(): + try: + asyncio.run_coroutine_threadsafe(coroutine, target_loop) + except Exception as exc: # pragma: no cover - defensive guard + logger.debug("Failed to dispatch name display update: %s", exc) + continue + + try: + asyncio.run(coroutine) + except RuntimeError: + logger.debug("Skipping name display update due to closed loop") def _save_settings(self) -> None: """Save settings to file""" diff --git a/tests/services/test_settings_manager.py b/tests/services/test_settings_manager.py index a6f1c0ed..47e385a4 100644 --- a/tests/services/test_settings_manager.py +++ b/tests/services/test_settings_manager.py @@ -1,4 +1,6 @@ +import asyncio import copy +import threading import json import os @@ -110,15 +112,30 @@ def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch): manager = _create_manager_with_settings(tmp_path, monkeypatch, initial) + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + class DummyScanner: def __init__(self): self.calls = [] + self.loop = loop async def on_model_name_display_changed(self, mode: str) -> None: self.calls.append(mode) dummy_scanner = DummyScanner() + dispatched_loops = [] + futures = [] + original_run_coroutine_threadsafe = asyncio.run_coroutine_threadsafe + + def tracking_run_coroutine_threadsafe(coro, target_loop): + dispatched_loops.append(target_loop) + future = original_run_coroutine_threadsafe(coro, target_loop) + futures.append(future) + return future + def fake_get_service_sync(cls, name): return dummy_scanner if name == "lora_scanner" else None @@ -127,10 +144,20 @@ def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch): "get_service_sync", classmethod(fake_get_service_sync), ) + monkeypatch.setattr(asyncio, "run_coroutine_threadsafe", tracking_run_coroutine_threadsafe) - manager.set("model_name_display", "file_name") + try: + manager.set("model_name_display", "file_name") - assert dummy_scanner.calls == ["file_name"] + for future in futures: + future.result(timeout=1) + + assert dummy_scanner.calls == ["file_name"] + assert dispatched_loops == [dummy_scanner.loop] + finally: + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=1) + loop.close() def test_migrates_legacy_settings_file(tmp_path, monkeypatch):