"""插件运行目录与本地仓库的文件变化监控。""" from __future__ import annotations import threading import time from collections.abc import Callable from pathlib import Path from typing import Any, Optional FederatedChangeResolver = Callable[[Path], Optional[tuple[str, Optional[dict], bool]]] RuntimePluginResolver = Callable[[Path], Optional[str]] MonitorSuppression = Callable[[str], bool] LocalCandidateResolver = Callable[[Path], Optional[dict]] LocalPluginSync = Callable[[str, Optional[dict]], bool] PluginReloader = Callable[[str], Any] DependencyManifestStatus = Callable[[Path], Optional[bool]] WatchFunction = Callable[..., Any] class PluginMonitorController: """独立管理插件文件监控线程的启动、停止和重建。""" def __init__(self, *, runner: Callable[[], None], log: Any) -> None: """保存监控循环入口和日志端口,线程状态仅由本组件持有。""" self._runner = runner self._logger = log self._thread: Optional[threading.Thread] = None self._stop_event = threading.Event() self._lifecycle_lock = threading.RLock() self._closed = False @property def stop_event(self) -> threading.Event: """返回供 watchfiles 监听的停止事件。""" return self._stop_event def reload(self, enabled: bool) -> None: """按当前配置停止旧线程,并在启用时创建新线程。""" stopped = self.stop() if enabled and stopped: self.start() def start(self) -> None: """启动唯一的守护监控线程。""" with self._lifecycle_lock: if self._closed: self._logger.info("插件文件修改监测已进入停机封口,跳过启动") return if self._thread and self._thread.is_alive(): self._logger.info("插件文件修改监测已经在运行中...") return self._logger.info("开始监测插件文件修改...") self._stop_event.clear() self._thread = threading.Thread(target=self._runner, daemon=True) self._thread.start() def reopen(self) -> bool: """为新的应用生命周期解除封口,仍有旧线程时拒绝重开。""" with self._lifecycle_lock: if self._thread and self._thread.is_alive(): self._logger.warning("旧插件文件监测线程仍在运行,无法开启新生命周期") return False self._thread = None self._closed = False return True def stop(self, timeout: float = 5.0) -> bool: """临时停止监控线程,并返回其是否在预算内真正退出。""" return self._stop_with_budget(timeout=timeout, close=False) def close(self, timeout: float = 5.0) -> bool: """永久封口当前生命周期,并返回监控线程是否真正退出。""" return self._stop_with_budget(timeout=timeout, close=True) def _stop_with_budget(self, *, timeout: float, close: bool) -> bool: """在同一预算内取得生命周期锁、设置封口并等待线程退出。""" timeout = max(0.0, timeout) deadline = time.monotonic() + timeout self._stop_event.set() if not self._lifecycle_lock.acquire(timeout=timeout): self._logger.warning( f"插件文件修改监测线程在{timeout:g}秒内未能取得停机所有权。" ) return False try: if close: self._closed = True thread = self._thread self._stop_event.set() if not thread or not thread.is_alive(): self._thread = None self._logger.info("未启用插件文件修改监测,无需停止") return True self._logger.info("正在停止插件文件修改监测...") thread.join(timeout=max(0.0, deadline - time.monotonic())) if thread.is_alive(): self._logger.warning( f"插件文件修改监测线程在{timeout:g}秒内未能正常停止。" ) return False self._thread = None self._logger.info("插件文件修改监测停止完成") return True finally: self._lifecycle_lock.release() class PluginChangeMonitor: """把文件变化归并为本地同步和运行态重载动作。""" def __init__( self, *, runtime_root: Path, local_roots: Callable[[], list[Path]], stop_event: Any, recent_sync: dict[str, float], federated_change: FederatedChangeResolver, runtime_plugin: RuntimePluginResolver, local_candidate: LocalCandidateResolver, sync_local: LocalPluginSync, reload_plugin: PluginReloader, dependency_manifest_status: DependencyManifestStatus, watch: WatchFunction, log: Any, monitor_suppressed: Optional[MonitorSuppression] = None, ) -> None: """保存监控路径、变化解析器和副作用回调。""" self._runtime_root = runtime_root self._local_roots = local_roots self._stop_event = stop_event self._recent_sync = recent_sync self._federated_change = federated_change self._runtime_plugin = runtime_plugin self._monitor_suppressed = monitor_suppressed or (lambda _plugin_id: False) self._local_candidate = local_candidate self._sync_local = sync_local self._reload_plugin = reload_plugin self._dependency_manifest_status = dependency_manifest_status self._watch = watch self._logger = log def run(self) -> None: """运行 watchfiles 主循环并按批次同步、重载插件。""" plugin_paths = [str(self._runtime_root)] plugin_paths.extend( str(path) for path in self._local_roots() if path.exists() and path.is_dir() ) self._logger.info(">>> 监控线程已启动,准备进入watch循环...") for changes in self._watch( *plugin_paths, stop_event=self._stop_event, rust_timeout=1000, yield_on_timeout=True, ): if not changes: continue self._process_changes(changes) def _process_changes(self, changes: Any) -> None: """把一批文件事件归并为最多一次同步和一次重载。""" plugins_to_reload = set() local_plugins_to_sync = {} for _change_type, path_str in changes: event_path = Path(path_str) if "__pycache__" in event_path.parts: continue manifest_status = self._dependency_manifest_status(event_path) if manifest_status is not None: self._handle_dependency_manifest_change( event_path, active=manifest_status, ) continue federated_change = self._federated_change(event_path) if federated_change: plugin_id, candidate, remote_entry_ready = federated_change if candidate and remote_entry_ready: if candidate.get("compatible") is False: self._logger.info( f"检测到本地插件 {plugin_id} 联邦构建产物变化," f"但跳过同步:{candidate.get('skip_reason')}" ) elif plugin_id not in local_plugins_to_sync: local_plugins_to_sync[plugin_id] = ( candidate, event_path, False, ) continue if event_path.suffix != ".py": continue runtime_plugin_id = self._runtime_plugin(event_path) if runtime_plugin_id and self._monitor_suppressed(runtime_plugin_id): self._logger.debug( f"插件 {runtime_plugin_id} 正在写入,跳过本批文件监控重载" ) continue candidate = ( self._local_candidate(event_path) if not runtime_plugin_id else None ) if runtime_plugin_id: last_sync_time = self._recent_sync.get(runtime_plugin_id) if last_sync_time and time.time() - last_sync_time < 2: continue plugins_to_reload.add(runtime_plugin_id) elif candidate: if candidate.get("compatible") is False: package_version = candidate.get("package_version") source_root = ( f"plugins.{package_version}" if package_version else "plugins" ) self._logger.info( f"检测到本地插件 {candidate.get('id')} 文件变化," f"来源:{source_root},文件:{event_path}," f"但跳过同步:{candidate.get('skip_reason')}" ) continue local_plugins_to_sync[candidate.get("id")] = ( candidate, event_path, True, ) for plugin_id, (candidate, event_path, should_reload) in ( local_plugins_to_sync.items() ): package_version = candidate.get("package_version") source_root = ( f"plugins.{package_version}" if package_version else "plugins" ) change_name = "Python 文件" if should_reload else "联邦构建产物" self._logger.info( f"检测到本地插件 {plugin_id} {change_name}变化," f"来源:{source_root},文件:{event_path}" ) if self._sync_local(plugin_id, candidate) and should_reload: plugins_to_reload.add(plugin_id) if not plugins_to_reload: return self._logger.info( f"检测到插件文件变化,准备重载: {list(plugins_to_reload)}" ) for plugin_id in plugins_to_reload: try: self._reload_plugin(plugin_id) except Exception as err: self._logger.error( f"插件 {plugin_id} 热重载失败: {err}", exc_info=True, ) def _handle_dependency_manifest_change( self, event_path: Path, *, active: bool, ) -> None: """记录依赖文件变化,但不在监控线程中隐式安装依赖。""" candidate = self._local_candidate(event_path) if not candidate: return if candidate.get("compatible") is False: self._logger.info( f"检测到本地插件 {candidate.get('id')} 依赖文件变化," f"但跳过处理:{candidate.get('skip_reason')}" ) return if not active: self._logger.debug( f"检测到本地插件 {candidate.get('id')} 非生效依赖文件变化:" f"{event_path.name}" ) return self._logger.warning( f"检测到本地插件 {candidate.get('id')} 依赖文件变化," "请重新安装本地插件以安装依赖" )