mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 00:16:57 +08:00
fix(modules): serialize configuration reload lifecycle (#6122)
This commit is contained in:
+5
-6
@@ -58,12 +58,11 @@ class ModuleManager(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
logger.info("正在停止所有模块...")
|
logger.info("正在停止所有模块...")
|
||||||
for module_id, module in self._running_modules.items():
|
for module_id, module in self._running_modules.items():
|
||||||
if hasattr(module, "stop"):
|
try:
|
||||||
try:
|
module.stop()
|
||||||
module.stop()
|
logger.debug(f"Moudle Stoped:{module_id}")
|
||||||
logger.debug(f"Moudle Stoped:{module_id}")
|
except Exception as err:
|
||||||
except Exception as err:
|
logger.error(f"Stop Moudle Error:{module_id},{str(err)} - {traceback.format_exc()}", exc_info=True)
|
||||||
logger.error(f"Stop Moudle Error:{module_id},{str(err)} - {traceback.format_exc()}", exc_info=True)
|
|
||||||
logger.info("所有模块停止完成")
|
logger.info("所有模块停止完成")
|
||||||
|
|
||||||
def reload(self):
|
def reload(self):
|
||||||
|
|||||||
+17
-2
@@ -1,8 +1,10 @@
|
|||||||
|
import threading
|
||||||
from abc import abstractmethod, ABCMeta
|
from abc import abstractmethod, ABCMeta
|
||||||
from typing import Generic, Tuple, Union, TypeVar, Type, Dict, Optional, Callable
|
from typing import Generic, Tuple, Union, TypeVar, Type, Dict, Optional, Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.helper.service import ServiceConfigHelper
|
from app.helper.service import ServiceConfigHelper
|
||||||
|
from app.log import logger
|
||||||
from app.schemas import Notification, NotificationConf, MediaServerConf, DownloaderConf
|
from app.schemas import Notification, NotificationConf, MediaServerConf, DownloaderConf
|
||||||
from app.schemas.types import ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
|
from app.schemas.types import ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
|
||||||
OtherModulesType, SystemConfigKey
|
OtherModulesType, SystemConfigKey
|
||||||
@@ -15,8 +17,21 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta):
|
|||||||
输入参数与输出参数一致的,或没有输出的,可以被多个模块重复实现
|
输入参数与输出参数一致的,或没有输出的,可以被多个模块重复实现
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def on_config_changed(self):
|
def __init__(self) -> None:
|
||||||
self.init_module()
|
"""初始化模块生命周期锁"""
|
||||||
|
super().__init__()
|
||||||
|
self._reload_lock = threading.RLock()
|
||||||
|
|
||||||
|
def on_config_changed(self) -> None:
|
||||||
|
"""串行停止旧资源并按最新配置重新初始化模块"""
|
||||||
|
with self._reload_lock:
|
||||||
|
try:
|
||||||
|
self.stop()
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(
|
||||||
|
f"停止 {self.get_reload_name()} 旧资源失败,继续按最新配置初始化:{err}"
|
||||||
|
)
|
||||||
|
self.init_module()
|
||||||
|
|
||||||
def get_reload_name(self):
|
def get_reload_name(self):
|
||||||
return self.get_name()
|
return self.get_name()
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
|||||||
if not Discord:
|
if not Discord:
|
||||||
logger.error("Discord 依赖未就绪(需要安装 discord.py==2.6.4),模块未启动")
|
logger.error("Discord 依赖未就绪(需要安装 discord.py==2.6.4),模块未启动")
|
||||||
return
|
return
|
||||||
self.stop()
|
|
||||||
super().init_service(
|
super().init_service(
|
||||||
service_name=Discord.__name__.lower(), service_type=Discord
|
service_name=Discord.__name__.lower(), service_type=Discord
|
||||||
)
|
)
|
||||||
@@ -89,12 +88,13 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
|||||||
"""
|
"""
|
||||||
return 4
|
return 4
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> None:
|
||||||
"""
|
"""停止模块"""
|
||||||
停止模块
|
|
||||||
"""
|
|
||||||
for client in self.get_instances().values():
|
for client in self.get_instances().values():
|
||||||
client.stop()
|
try:
|
||||||
|
client.stop()
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"停止Discord模块实例失败:{err}")
|
||||||
|
|
||||||
def test(self) -> Optional[Tuple[bool, str]]:
|
def test(self) -> Optional[Tuple[bool, str]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from app.schemas.types import ModuleType
|
|||||||
|
|
||||||
class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
|
class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
|
||||||
def init_module(self) -> None:
|
def init_module(self) -> None:
|
||||||
self.stop()
|
|
||||||
super().init_service(service_name=Feishu.__name__.lower(), service_type=Feishu)
|
super().init_service(service_name=Feishu.__name__.lower(), service_type=Feishu)
|
||||||
self._channel = MessageChannel.Feishu
|
self._channel = MessageChannel.Feishu
|
||||||
|
|
||||||
@@ -30,13 +29,13 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
|
|||||||
def get_priority() -> int:
|
def get_priority() -> int:
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> None:
|
||||||
|
"""停止模块"""
|
||||||
for client in self.get_instances().values():
|
for client in self.get_instances().values():
|
||||||
if hasattr(client, "stop"):
|
try:
|
||||||
try:
|
client.stop()
|
||||||
client.stop()
|
except Exception as err:
|
||||||
except Exception as err:
|
logger.error(f"停止飞书模块实例失败:{err}")
|
||||||
logger.error(f"停止飞书模块实例失败:{err}")
|
|
||||||
|
|
||||||
def test(self) -> Optional[Tuple[bool, str]]:
|
def test(self) -> Optional[Tuple[bool, str]]:
|
||||||
if not self.get_instances():
|
if not self.get_instances():
|
||||||
|
|||||||
@@ -92,13 +92,6 @@ class FilterModule(_ModuleBase):
|
|||||||
self.rule_set = deepcopy(self.builtin_rule_set)
|
self.rule_set = deepcopy(self.builtin_rule_set)
|
||||||
self.__init_custom_rules()
|
self.__init_custom_rules()
|
||||||
|
|
||||||
def on_config_changed(self) -> None:
|
|
||||||
"""
|
|
||||||
自定义过滤或 Meta 识别配置变更后重建规则集并刷新 Rust Meta 配置缓存。
|
|
||||||
"""
|
|
||||||
clear_rust_parse_options_cache()
|
|
||||||
self.init_module()
|
|
||||||
|
|
||||||
def __init_custom_rules(self):
|
def __init_custom_rules(self):
|
||||||
"""
|
"""
|
||||||
加载用户自定义规则,如跟内置规则冲突,以用户自定义规则为准
|
加载用户自定义规则,如跟内置规则冲突,以用户自定义规则为准
|
||||||
@@ -137,10 +130,8 @@ class FilterModule(_ModuleBase):
|
|||||||
return 4
|
return 4
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""
|
"""停止模块"""
|
||||||
停止过滤器模块。
|
clear_rust_parse_options_cache()
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test(self) -> None:
|
def test(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -44,13 +44,14 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
|
|||||||
"""
|
"""
|
||||||
return 3
|
return 3
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> None:
|
||||||
"""
|
"""停止模块"""
|
||||||
停止模块服务
|
|
||||||
"""
|
|
||||||
for server in self.get_instances().values():
|
for server in self.get_instances().values():
|
||||||
if server:
|
try:
|
||||||
server.close()
|
if server:
|
||||||
|
server.close()
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"停止Plex模块实例失败:{err}")
|
||||||
|
|
||||||
def test(self) -> Optional[Tuple[bool, str]]:
|
def test(self) -> Optional[Tuple[bool, str]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def init_module(self) -> None:
|
def init_module(self) -> None:
|
||||||
self.stop()
|
|
||||||
super().init_service(service_name=QQBot.__name__.lower(), service_type=QQBot)
|
super().init_service(service_name=QQBot.__name__.lower(), service_type=QQBot)
|
||||||
self._channel = MessageChannel.QQ
|
self._channel = MessageChannel.QQ
|
||||||
|
|
||||||
@@ -67,9 +66,12 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
|
|||||||
return 10
|
return 10
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
|
"""停止模块"""
|
||||||
for client in self.get_instances().values():
|
for client in self.get_instances().values():
|
||||||
if hasattr(client, "stop"):
|
try:
|
||||||
client.stop()
|
client.stop()
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"停止QQ Bot模块实例失败:{err}")
|
||||||
|
|
||||||
def test(self) -> Optional[Tuple[bool, str]]:
|
def test(self) -> Optional[Tuple[bool, str]]:
|
||||||
if not self.get_instances():
|
if not self.get_instances():
|
||||||
|
|||||||
@@ -69,12 +69,13 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
|||||||
"""
|
"""
|
||||||
return 3
|
return 3
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> None:
|
||||||
"""
|
"""停止模块"""
|
||||||
停止模块
|
|
||||||
"""
|
|
||||||
for client in self.get_instances().values():
|
for client in self.get_instances().values():
|
||||||
client.stop()
|
try:
|
||||||
|
client.stop()
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"停止Slack模块实例失败:{err}")
|
||||||
|
|
||||||
def test(self) -> Optional[Tuple[bool, str]]:
|
def test(self) -> Optional[Tuple[bool, str]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -62,12 +62,13 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
|||||||
"""
|
"""
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> None:
|
||||||
"""
|
"""停止模块"""
|
||||||
停止模块
|
|
||||||
"""
|
|
||||||
for client in self.get_instances().values():
|
for client in self.get_instances().values():
|
||||||
client.stop()
|
try:
|
||||||
|
client.stop()
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"停止Telegram模块实例失败:{err}")
|
||||||
|
|
||||||
def test(self) -> Optional[Tuple[bool, str]]:
|
def test(self) -> Optional[Tuple[bool, str]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1536,14 +1536,19 @@ class Telegram:
|
|||||||
# 清理菜单命令
|
# 清理菜单命令
|
||||||
self._bot.delete_my_commands()
|
self._bot.delete_my_commands()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> None:
|
||||||
"""
|
"""
|
||||||
停止Telegram消息接收服务
|
停止Telegram消息接收服务
|
||||||
"""
|
"""
|
||||||
# 停止所有typing任务
|
# 停止所有typing任务
|
||||||
for chat_id in list(self._typing_tasks.keys()):
|
for chat_id in list(self._typing_tasks.keys()):
|
||||||
self._stop_typing_task(chat_id)
|
self._stop_typing_task(chat_id)
|
||||||
if self._bot:
|
if not self._bot:
|
||||||
self._bot.stop_polling()
|
return
|
||||||
|
|
||||||
|
self._bot.stop_bot()
|
||||||
|
if self._polling_thread:
|
||||||
self._polling_thread.join()
|
self._polling_thread.join()
|
||||||
logger.info("Telegram消息接收服务已停止")
|
self._polling_thread = None
|
||||||
|
self._bot = None
|
||||||
|
logger.info("Telegram消息接收服务已停止")
|
||||||
|
|||||||
@@ -43,12 +43,6 @@ class TheMovieDbModule(_ModuleBase):
|
|||||||
self.category = CategoryHelper()
|
self.category = CategoryHelper()
|
||||||
self.scraper = TmdbScraper()
|
self.scraper = TmdbScraper()
|
||||||
|
|
||||||
def on_config_changed(self):
|
|
||||||
# 停止模块
|
|
||||||
self.stop()
|
|
||||||
# 初始化模块
|
|
||||||
self.init_module()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_name() -> str:
|
def get_name() -> str:
|
||||||
return "TheMovieDb"
|
return "TheMovieDb"
|
||||||
@@ -74,9 +68,13 @@ class TheMovieDbModule(_ModuleBase):
|
|||||||
"""
|
"""
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> None:
|
||||||
self.cache.save()
|
"""停止模块"""
|
||||||
self.tmdb.close()
|
# 缓存持久化失败不能阻断 HTTP 客户端关闭
|
||||||
|
try:
|
||||||
|
self.cache.save()
|
||||||
|
finally:
|
||||||
|
self.tmdb.close()
|
||||||
|
|
||||||
def test(self) -> Tuple[bool, str]:
|
def test(self) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -61,10 +61,14 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
|
|||||||
logger.info(f"飞牛影视 {name} 连接断开,尝试重连 ...")
|
logger.info(f"飞牛影视 {name} 连接断开,尝试重连 ...")
|
||||||
server.reconnect()
|
server.reconnect()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> None:
|
||||||
|
"""停止模块"""
|
||||||
for server in self.get_instances().values():
|
for server in self.get_instances().values():
|
||||||
if server.is_authenticated():
|
try:
|
||||||
server.disconnect()
|
if server.is_authenticated():
|
||||||
|
server.disconnect()
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"停止飞牛影视模块实例失败:{err}")
|
||||||
|
|
||||||
def test(self) -> Optional[Tuple[bool, str]]:
|
def test(self) -> Optional[Tuple[bool, str]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -60,10 +60,14 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]):
|
|||||||
logger.info(f"绿联影视 {name} 连接断开,尝试重连 ...")
|
logger.info(f"绿联影视 {name} 连接断开,尝试重连 ...")
|
||||||
server.reconnect()
|
server.reconnect()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> None:
|
||||||
|
"""停止模块"""
|
||||||
for server in self.get_instances().values():
|
for server in self.get_instances().values():
|
||||||
if server.is_authenticated():
|
try:
|
||||||
server.disconnect()
|
if server.is_authenticated():
|
||||||
|
server.disconnect()
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"停止绿联影视模块实例失败:{err}")
|
||||||
|
|
||||||
def test(self) -> Optional[Tuple[bool, str]]:
|
def test(self) -> Optional[Tuple[bool, str]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
|
|||||||
"""
|
"""
|
||||||
初始化模块
|
初始化模块
|
||||||
"""
|
"""
|
||||||
self.stop()
|
|
||||||
super().init_service(service_name=WeChat.__name__.lower(),
|
super().init_service(service_name=WeChat.__name__.lower(),
|
||||||
service_type=self._create_client)
|
service_type=self._create_client)
|
||||||
self._channel = MessageChannel.Wechat
|
self._channel = MessageChannel.Wechat
|
||||||
@@ -54,13 +53,14 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
|
|||||||
"""
|
"""
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> None:
|
||||||
|
"""停止模块"""
|
||||||
for client in self.get_instances().values():
|
for client in self.get_instances().values():
|
||||||
if hasattr(client, "stop"):
|
try:
|
||||||
try:
|
if hasattr(client, "stop"):
|
||||||
client.stop()
|
client.stop()
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"停止微信模块实例失败:{err}")
|
logger.error(f"停止微信模块实例失败:{err}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_bot_mode(config: dict) -> bool:
|
def _is_bot_mode(config: dict) -> bool:
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
|
|||||||
|
|
||||||
def init_module(self) -> None:
|
def init_module(self) -> None:
|
||||||
"""初始化模块。"""
|
"""初始化模块。"""
|
||||||
self.stop()
|
|
||||||
super().init_service(
|
super().init_service(
|
||||||
service_name=WechatClawBot.__name__.lower(), service_type=WechatClawBot
|
service_name=WechatClawBot.__name__.lower(), service_type=WechatClawBot
|
||||||
)
|
)
|
||||||
@@ -49,14 +48,13 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
|
|||||||
"""获取模块优先级。"""
|
"""获取模块优先级。"""
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> None:
|
||||||
"""停止模块。"""
|
"""停止模块"""
|
||||||
for client in self.get_instances().values():
|
for client in self.get_instances().values():
|
||||||
if hasattr(client, "stop"):
|
try:
|
||||||
try:
|
client.stop()
|
||||||
client.stop()
|
except Exception as err:
|
||||||
except Exception as err:
|
logger.error(f"停止微信 ClawBot 模块实例失败:{err}")
|
||||||
logger.error(f"停止微信 ClawBot 模块实例失败:{err}")
|
|
||||||
|
|
||||||
def test(self) -> Optional[Tuple[bool, str]]:
|
def test(self) -> Optional[Tuple[bool, str]]:
|
||||||
"""测试模块连接性。"""
|
"""测试模块连接性。"""
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import threading
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.modules import _MessageBase
|
||||||
|
from app.modules.discord import DiscordModule
|
||||||
|
from app.modules.feishu import FeishuModule
|
||||||
|
from app.modules.filter import FilterModule
|
||||||
|
from app.modules.plex import PlexModule
|
||||||
|
from app.modules.qqbot import QQBotModule
|
||||||
|
from app.modules.slack import SlackModule
|
||||||
|
from app.modules.telegram import TelegramModule
|
||||||
|
from app.modules.telegram.telegram import Telegram
|
||||||
|
from app.modules.themoviedb import TheMovieDbModule
|
||||||
|
from app.modules.trimemedia import TrimeMediaModule
|
||||||
|
from app.modules.ugreen import UgreenModule
|
||||||
|
from app.modules.wechat import WechatModule
|
||||||
|
from app.modules.wechatclawbot import WechatClawBotModule
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_reload_stops_before_initializing_latest_generation():
|
||||||
|
"""同一模块的重载必须串行,并依次停止和初始化 generation。"""
|
||||||
|
module = TelegramModule()
|
||||||
|
call_order = []
|
||||||
|
reload_started = threading.Event()
|
||||||
|
reload_finished = threading.Event()
|
||||||
|
|
||||||
|
def reload_module():
|
||||||
|
reload_started.set()
|
||||||
|
module.on_config_changed()
|
||||||
|
reload_finished.set()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
module, "stop", side_effect=lambda: call_order.append("stop")
|
||||||
|
), patch.object(
|
||||||
|
_MessageBase,
|
||||||
|
"init_service",
|
||||||
|
side_effect=lambda **_kwargs: call_order.append("init"),
|
||||||
|
):
|
||||||
|
module._reload_lock.acquire()
|
||||||
|
try:
|
||||||
|
reload_thread = threading.Thread(target=reload_module)
|
||||||
|
reload_thread.start()
|
||||||
|
assert reload_started.wait(1)
|
||||||
|
assert not reload_finished.wait(0.1)
|
||||||
|
finally:
|
||||||
|
module._reload_lock.release()
|
||||||
|
|
||||||
|
assert reload_finished.wait(1)
|
||||||
|
reload_thread.join()
|
||||||
|
|
||||||
|
assert call_order == ["stop", "init"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_initialization_does_not_stop_a_fresh_module_generation():
|
||||||
|
"""首次初始化只创建资源,停止旧 generation 由重载入口负责。"""
|
||||||
|
module = TelegramModule()
|
||||||
|
|
||||||
|
with patch.object(module, "stop") as stop, patch.object(
|
||||||
|
_MessageBase, "init_service"
|
||||||
|
) as init_service:
|
||||||
|
module.init_module()
|
||||||
|
|
||||||
|
stop.assert_not_called()
|
||||||
|
init_service.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_reload_initializes_latest_generation_after_stop_failure():
|
||||||
|
"""旧资源停止异常只记录错误,不阻止最新配置完成初始化。"""
|
||||||
|
module = TelegramModule()
|
||||||
|
call_order = []
|
||||||
|
|
||||||
|
def stop_with_failure():
|
||||||
|
call_order.append("stop")
|
||||||
|
raise RuntimeError("stop failed")
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
module,
|
||||||
|
"stop",
|
||||||
|
side_effect=stop_with_failure,
|
||||||
|
), patch.object(
|
||||||
|
_MessageBase,
|
||||||
|
"init_service",
|
||||||
|
side_effect=lambda **_kwargs: call_order.append("init"),
|
||||||
|
):
|
||||||
|
module.on_config_changed()
|
||||||
|
|
||||||
|
assert call_order == ["stop", "init"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmdb_reload_closes_old_client_when_cache_save_fails():
|
||||||
|
"""TMDB 缓存保存失败时仍须关闭旧客户端并初始化最新配置。"""
|
||||||
|
module = TheMovieDbModule()
|
||||||
|
module.cache = Mock()
|
||||||
|
module.cache.save.side_effect = OSError("cache write failed")
|
||||||
|
module.tmdb = Mock()
|
||||||
|
|
||||||
|
with patch.object(module, "init_module") as init_module:
|
||||||
|
module.on_config_changed()
|
||||||
|
|
||||||
|
module.tmdb.close.assert_called_once_with()
|
||||||
|
init_module.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_reload_uses_shared_module_lifecycle_lock():
|
||||||
|
"""过滤规则重载必须经过模块基类的串行 stop 和 init。"""
|
||||||
|
module = FilterModule()
|
||||||
|
reload_started = threading.Event()
|
||||||
|
reload_finished = threading.Event()
|
||||||
|
call_order = []
|
||||||
|
|
||||||
|
def reload_module():
|
||||||
|
reload_started.set()
|
||||||
|
module.on_config_changed()
|
||||||
|
reload_finished.set()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.modules.filter.clear_rust_parse_options_cache",
|
||||||
|
side_effect=lambda: call_order.append("stop"),
|
||||||
|
), patch.object(
|
||||||
|
module, "init_module", side_effect=lambda: call_order.append("init")
|
||||||
|
):
|
||||||
|
module._reload_lock.acquire()
|
||||||
|
try:
|
||||||
|
reload_thread = threading.Thread(target=reload_module)
|
||||||
|
reload_thread.start()
|
||||||
|
assert reload_started.wait(1)
|
||||||
|
assert not reload_finished.wait(0.1)
|
||||||
|
finally:
|
||||||
|
module._reload_lock.release()
|
||||||
|
|
||||||
|
assert reload_finished.wait(1)
|
||||||
|
reload_thread.join()
|
||||||
|
|
||||||
|
assert call_order == ["stop", "init"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("module_type", "stop_method", "requires_authentication"),
|
||||||
|
[
|
||||||
|
(DiscordModule, "stop", False),
|
||||||
|
(FeishuModule, "stop", False),
|
||||||
|
(QQBotModule, "stop", False),
|
||||||
|
(SlackModule, "stop", False),
|
||||||
|
(TelegramModule, "stop", False),
|
||||||
|
(WechatModule, "stop", False),
|
||||||
|
(WechatClawBotModule, "stop", False),
|
||||||
|
(PlexModule, "close", False),
|
||||||
|
(TrimeMediaModule, "disconnect", True),
|
||||||
|
(UgreenModule, "disconnect", True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_module_stop_isolates_each_service_instance(
|
||||||
|
module_type, stop_method, requires_authentication
|
||||||
|
):
|
||||||
|
"""单个服务停止失败时必须继续关闭同模块的其余实例。"""
|
||||||
|
module = module_type()
|
||||||
|
failed_client = Mock()
|
||||||
|
healthy_client = Mock()
|
||||||
|
getattr(failed_client, stop_method).side_effect = RuntimeError("stop failed")
|
||||||
|
if requires_authentication:
|
||||||
|
failed_client.is_authenticated.return_value = True
|
||||||
|
healthy_client.is_authenticated.return_value = True
|
||||||
|
module._instances = {"failed": failed_client, "healthy": healthy_client}
|
||||||
|
|
||||||
|
module.stop()
|
||||||
|
|
||||||
|
getattr(failed_client, stop_method).assert_called_once_with()
|
||||||
|
getattr(healthy_client, stop_method).assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_stop_closes_sdk_and_waits_for_polling_thread():
|
||||||
|
"""客户端停止完成后不得保留 SDK worker 或 polling 线程句柄。"""
|
||||||
|
client = Telegram.__new__(Telegram)
|
||||||
|
bot = Mock()
|
||||||
|
client._bot = bot
|
||||||
|
polling_thread = Mock()
|
||||||
|
client._polling_thread = polling_thread
|
||||||
|
|
||||||
|
client.stop()
|
||||||
|
client.stop()
|
||||||
|
|
||||||
|
bot.stop_bot.assert_called_once_with()
|
||||||
|
polling_thread.join.assert_called_once_with()
|
||||||
|
assert client._bot is None
|
||||||
|
assert client._polling_thread is None
|
||||||
Reference in New Issue
Block a user