fix: deduplicate graceful restart fallback

This commit is contained in:
jxxghp
2026-08-24 08:29:07 +08:00
parent e2814a129f
commit 01d29d952f
3 changed files with 191 additions and 101 deletions
+30 -5
View File
@@ -38,6 +38,8 @@ class SystemHelper(ConfigReloadMixin):
__local_restart_log_file = settings.LOG_PATH / "moviepilot.restart.stdout.log" __local_restart_log_file = settings.LOG_PATH / "moviepilot.restart.stdout.log"
__one_shot_update_flag_file = settings.TEMP_PATH / "moviepilot.pending_update" __one_shot_update_flag_file = settings.TEMP_PATH / "moviepilot.pending_update"
__docker_restart_intent_file = settings.TEMP_PATH / "moviepilot.intentional_restart" __docker_restart_intent_file = settings.TEMP_PATH / "moviepilot.intentional_restart"
__graceful_shutdown_monitor_lock = threading.Lock()
__graceful_shutdown_monitor: Optional[threading.Thread] = None
def on_config_changed(self): def on_config_changed(self):
"""配置变化后重新应用日志设置。""" """配置变化后重新应用日志设置。"""
@@ -360,21 +362,44 @@ class SystemHelper(ConfigReloadMixin):
@staticmethod @staticmethod
def _start_graceful_shutdown_monitor(): def _start_graceful_shutdown_monitor():
""" """
启动优雅退出超时监控 启动唯一的优雅退出超时监控
如果30秒内进程没有退出,则使用Docker API强制重启
如果 180 秒内进程没有退出,则使用 Docker API 强制重启;重复重启请求
复用当前 monitor,避免并行触发多次容器重启。
""" """
def monitor_thread(): def monitor_thread():
time.sleep(180) # 等待180秒 try:
time.sleep(180)
logger.warning("优雅退出超时180秒,使用Docker API强制重启...") logger.warning("优雅退出超时180秒,使用Docker API强制重启...")
try: try:
SystemHelper._docker_api_restart() SystemHelper._docker_api_restart()
except Exception as e: except Exception as e:
logger.error(f"强制重启失败: {str(e)}") logger.error(f"强制重启失败: {str(e)}")
finally:
with SystemHelper.__graceful_shutdown_monitor_lock:
if (
SystemHelper.__graceful_shutdown_monitor
is threading.current_thread()
):
SystemHelper.__graceful_shutdown_monitor = None
# 在后台线程中启动监控 with SystemHelper.__graceful_shutdown_monitor_lock:
thread = threading.Thread(target=monitor_thread, daemon=True) running = SystemHelper.__graceful_shutdown_monitor
if running is not None and running.is_alive():
logger.debug("优雅退出超时监控已在运行,跳过重复启动")
return
thread = threading.Thread(
target=monitor_thread,
name="MoviePilot-GracefulRestartFallback",
daemon=True,
)
SystemHelper.__graceful_shutdown_monitor = thread
try:
thread.start() thread.start()
except BaseException:
SystemHelper.__graceful_shutdown_monitor = None
raise
@staticmethod @staticmethod
def _docker_api_restart() -> Tuple[bool, str]: def _docker_api_restart() -> Tuple[bool, str]:
@@ -6,7 +6,7 @@
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本 > 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
> 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文 > 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文
> 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md` > 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md`
> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口;阶段 14 已收口音乐订阅数据端口;阶段 15 已收口站点数据端口;阶段 16 已收口媒体服务器数据端口;阶段 17 已收口下载数据端口;阶段 18 已收口主订阅数据端口;阶段 19 已收口整理数据端口;阶段 20 已收口 Agent 数据端口;阶段 21 已收口监控历史端口;阶段 22 已统一服务配置应用边界;阶段 23 已补齐媒体服务器 API 遗留的类形配置读取路径;阶段 24 已清除 Scheduler 内部无 owner 的协程提交双轨;阶段 25 已补齐 TaskRegistry 跨线程 owner 并迁移整理 AI 接管;阶段 26 已统一 Agent 会话清理提交;阶段 27 已统一历史 AI 进度 owner;阶段 28 已托管旧插件订阅统计线程;阶段 29 已统一 Emby 系条目转换并清零重复代码白名单;阶段 30 已收口插件市场请求级子任务;阶段 31 已托管搜索 AI 推荐任务;阶段 32 已清除事件调度器绕过生命周期 owner 的投递回退;阶段 33 已统一宿主 Agent 运行时的获取路径;阶段 34 已统一 durable-required 事件与 Outbox topic 事实源;阶段 35 已统一 LLM provider 管理 API 的运行时解析路径;阶段 36 已统一 WebAgent 音频能力访问边界;阶段 37 已统一插件输入事件发布路径;阶段 38 已统一 WebAgent 通知事件监听与队列边界;阶段 39 已补齐搜索 SSE 断线时的上游任务清理;阶段 40 已补齐异步防抖取消的终态所有权。 > 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口;阶段 14 已收口音乐订阅数据端口;阶段 15 已收口站点数据端口;阶段 16 已收口媒体服务器数据端口;阶段 17 已收口下载数据端口;阶段 18 已收口主订阅数据端口;阶段 19 已收口整理数据端口;阶段 20 已收口 Agent 数据端口;阶段 21 已收口监控历史端口;阶段 22 已统一服务配置应用边界;阶段 23 已补齐媒体服务器 API 遗留的类形配置读取路径;阶段 24 已清除 Scheduler 内部无 owner 的协程提交双轨;阶段 25 已补齐 TaskRegistry 跨线程 owner 并迁移整理 AI 接管;阶段 26 已统一 Agent 会话清理提交;阶段 27 已统一历史 AI 进度 owner;阶段 28 已托管旧插件订阅统计线程;阶段 29 已统一 Emby 系条目转换并清零重复代码白名单;阶段 30 已收口插件市场请求级子任务;阶段 31 已托管搜索 AI 推荐任务;阶段 32 已清除事件调度器绕过生命周期 owner 的投递回退;阶段 33 已统一宿主 Agent 运行时的获取路径;阶段 34 已统一 durable-required 事件与 Outbox topic 事实源;阶段 35 已统一 LLM provider 管理 API 的运行时解析路径;阶段 36 已统一 WebAgent 音频能力访问边界;阶段 37 已统一插件输入事件发布路径;阶段 38 已统一 WebAgent 通知事件监听与队列边界;阶段 39 已补齐搜索 SSE 断线时的上游任务清理;阶段 40 已补齐异步防抖取消的终态所有权;阶段 41 已统一优雅重启兜底线程的唯一所有权
## 当前复核结论(2026-08-24 ## 当前复核结论(2026-08-24
@@ -426,6 +426,18 @@
- 回归测试覆盖旧任务在取消清理中阻塞、后续任务同时存在的场景。`debounce()` 装饰器、leading/trailing - 回归测试覆盖旧任务在取消清理中阻塞、后续任务同时存在的场景。`debounce()` 装饰器、leading/trailing
触发规则、同步 `Debouncer``app.utils.debounce` 兼容映射和 V1/V2/V3 插件导入路径均未修改。 触发规则、同步 `Debouncer``app.utils.debounce` 兼容映射和 V1/V2/V3 插件导入路径均未修改。
### 长期整改阶段 41:优雅重启兜底线程所有权收口(2026-08-24)
- API、命令链、升级和启动期资源更新都可能调用 `SystemHelper.restart()`Docker 优雅退出路径原先每次
请求都会创建一个独立 daemon monitor,连续请求会在 180 秒后并行触发多次强制容器重启。现在 monitor
`SystemHelper` 以锁和唯一句柄持有,存活期间的重复请求复用同一 owner,线程启动失败或执行结束都会
释放句柄,后续真实重启仍可创建新的兜底监控。
- 回归测试用事件屏障验证首线程存活期间不会重复创建,并验证强制重启只调用一次、终态 owner 被释放;
同时将触发说明从过期的 30 秒更正为实际的 180 秒。SIGTERM 优雅退出、Docker API 回退、意图标记、
本地 CLI 与一次性升级语义均未改变。
- 该 daemon monitor 故意跨越正常 shutdown drain,以便进程卡死时仍能请求 Docker 重启,因此不纳入
`TaskRegistry` 或普通线程池等待。`SystemHelper` 公开方法、SDK/Compat 映射和 V1/V2/V3 插件 ABI 均未修改。
### 总体判断 ### 总体判断
当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**: 当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**:
+93 -40
View File
@@ -6,9 +6,9 @@ import struct
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import threading
import time import time
from pathlib import Path from pathlib import Path
from unittest import TestCase
from unittest.mock import MagicMock, call, patch from unittest.mock import MagicMock, call, patch
import psutil import psutil
@@ -19,48 +19,41 @@ from app.runtime.config import ConfigModel, settings
from app.adapters.system.host import SystemUtils from app.adapters.system.host import SystemUtils
class SystemUtilsTest(TestCase): def test_get_config_path_uses_repository_config_for_source_runtime():
def test_get_config_path_uses_repository_config_for_source_runtime(self):
"""源码运行时应从项目根目录读取配置,不能随适配器目录层级偏移。""" """源码运行时应从项目根目录读取配置,不能随适配器目录层级偏移。"""
expected = Path(__file__).resolve().parents[1] / "config" expected = Path(__file__).resolve().parents[1] / "config"
with patch.dict(os.environ, {}, clear=True), \ with patch.dict(os.environ, {}, clear=True), \
patch.object(SystemUtils, "is_docker", return_value=False), \ patch.object(SystemUtils, "is_docker", return_value=False), \
patch.object(SystemUtils, "is_frozen", return_value=False): patch.object(SystemUtils, "is_frozen", return_value=False):
self.assertEqual(SystemUtils.get_config_path(), expected) assert SystemUtils.get_config_path() == expected
self.assertEqual(SystemUtils.get_env_path(), expected / "app.env") assert SystemUtils.get_env_path() == expected / "app.env"
def test_get_config_path_preserves_explicit_config_dir(self):
def test_get_config_path_preserves_explicit_config_dir():
"""显式配置目录始终优先于运行环境推导。""" """显式配置目录始终优先于运行环境推导。"""
explicit_path = Path("/custom/moviepilot-config") explicit_path = Path("/custom/moviepilot-config")
with patch.dict(os.environ, {"CONFIG_DIR": "/ignored"}, clear=True), \ with patch.dict(os.environ, {"CONFIG_DIR": "/ignored"}, clear=True), \
patch.object(SystemUtils, "is_docker", return_value=True): patch.object(SystemUtils, "is_docker", return_value=True):
self.assertEqual( assert SystemUtils.get_config_path(str(explicit_path)) == explicit_path
SystemUtils.get_config_path(str(explicit_path)),
explicit_path,
)
def test_get_config_path_preserves_runtime_specific_defaults(self):
def test_get_config_path_preserves_runtime_specific_defaults():
"""容器和冻结程序继续使用各自稳定的配置目录。""" """容器和冻结程序继续使用各自稳定的配置目录。"""
with patch.dict(os.environ, {}, clear=True), \ with patch.dict(os.environ, {}, clear=True), \
patch.object(SystemUtils, "is_docker", return_value=True): patch.object(SystemUtils, "is_docker", return_value=True):
self.assertEqual(SystemUtils.get_config_path(), Path("/config")) assert SystemUtils.get_config_path() == Path("/config")
with patch.dict(os.environ, {}, clear=True), \ with patch.dict(os.environ, {}, clear=True), \
patch.object(SystemUtils, "is_docker", return_value=False), \ patch.object(SystemUtils, "is_docker", return_value=False), \
patch.object(SystemUtils, "is_frozen", return_value=True), \ patch.object(SystemUtils, "is_frozen", return_value=True), \
patch("app.adapters.system.host.sys.executable", "/opt/moviepilot/moviepilot"): patch("app.adapters.system.host.sys.executable", "/opt/moviepilot/moviepilot"):
self.assertEqual( assert SystemUtils.get_config_path() == Path("/opt/moviepilot/config")
SystemUtils.get_config_path(),
Path("/opt/moviepilot/config"),
)
def test_execute_with_subprocess_keeps_stdout_when_command_fails(self):
""" def test_execute_with_subprocess_keeps_stdout_when_command_fails():
命令失败时如果原因只写入 stdout,也需要回传给调用方用于错误提示。 """命令失败时如果原因只写入 stdout,也需要回传给调用方用于错误提示。"""
"""
error = subprocess.CalledProcessError( error = subprocess.CalledProcessError(
returncode=1, returncode=1,
cmd=["pip", "check"], cmd=["pip", "check"],
@@ -71,14 +64,13 @@ class SystemUtilsTest(TestCase):
with patch("app.adapters.system.host.subprocess.run", side_effect=error): with patch("app.adapters.system.host.subprocess.run", side_effect=error):
success, message = SystemUtils.execute_with_subprocess(["pip", "check"]) success, message = SystemUtils.execute_with_subprocess(["pip", "check"])
self.assertFalse(success) assert not success
self.assertIn("返回码:1", message) assert "返回码:1" in message
self.assertIn("标准输出:demo requires pkg>=2, but you have pkg 1", message) assert "标准输出:demo requires pkg>=2, but you have pkg 1" in message
def test_execute_with_subprocess_reports_empty_failure_output(self):
""" def test_execute_with_subprocess_reports_empty_failure_output():
命令失败且没有任何输出时给出明确占位信息,避免错误原因看起来被截断。 """命令失败且没有输出时给出明确占位信息,避免错误原因看起来被截断。"""
"""
error = subprocess.CalledProcessError( error = subprocess.CalledProcessError(
returncode=2, returncode=2,
cmd=["pip", "check"], cmd=["pip", "check"],
@@ -89,17 +81,13 @@ class SystemUtilsTest(TestCase):
with patch("app.adapters.system.host.subprocess.run", side_effect=error): with patch("app.adapters.system.host.subprocess.run", side_effect=error):
success, message = SystemUtils.execute_with_subprocess(["pip", "check"]) success, message = SystemUtils.execute_with_subprocess(["pip", "check"])
self.assertFalse(success) assert not success
self.assertIn("返回码:2", message) assert "返回码:2" in message
self.assertIn("无标准输出或错误输出", message) assert "无标准输出或错误输出" in message
class SystemHelperRestartTest(TestCase): def test_docker_restart_policy_marks_intent_before_sigterm():
"""Docker 优雅重启前应写入意图标记,避免 entrypoint 误进入 doctor 保活。"""
def test_docker_restart_policy_marks_intent_before_sigterm(self):
"""
Docker 内置重启走优雅退出时,应写入意图标记,避免 entrypoint 误进入 doctor 保活。
"""
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
original_config_dir = settings.CONFIG_DIR original_config_dir = settings.CONFIG_DIR
original_intent_file = SystemHelper._SystemHelper__docker_restart_intent_file original_intent_file = SystemHelper._SystemHelper__docker_restart_intent_file
@@ -114,15 +102,80 @@ class SystemHelperRestartTest(TestCase):
patch("app.runtime.state.os.kill") as kill_mock: patch("app.runtime.state.os.kill") as kill_mock:
ret, msg = SystemHelper.restart() ret, msg = SystemHelper.restart()
self.assertTrue(ret) assert ret
self.assertEqual(msg, "") assert msg == ""
self.assertTrue((settings.TEMP_PATH / "moviepilot.intentional_restart").exists()) assert (settings.TEMP_PATH / "moviepilot.intentional_restart").exists()
kill_mock.assert_called_once() kill_mock.assert_called_once()
finally: finally:
SystemHelper._SystemHelper__docker_restart_intent_file = original_intent_file SystemHelper._SystemHelper__docker_restart_intent_file = original_intent_file
settings.CONFIG_DIR = original_config_dir settings.CONFIG_DIR = original_config_dir
def test_graceful_shutdown_monitor_has_single_owner_and_releases_it(monkeypatch):
"""重复重启请求应共享唯一兜底线程,线程结束后必须释放 owner。"""
sleep_started = threading.Event()
release_sleep = threading.Event()
restart = MagicMock(return_value=(True, ""))
monitor_attr = "_SystemHelper__graceful_shutdown_monitor"
original_monitor = getattr(SystemHelper, monitor_attr)
thread = None
def wait_for_shutdown(_seconds: float) -> None:
"""用事件屏障模拟 180 秒等待,确保第二次启动发生在首线程存活期间。"""
sleep_started.set()
release_sleep.wait(timeout=1)
setattr(SystemHelper, monitor_attr, None)
try:
monkeypatch.setattr("app.runtime.state.time.sleep", wait_for_shutdown)
monkeypatch.setattr(SystemHelper, "_docker_api_restart", restart)
SystemHelper._start_graceful_shutdown_monitor()
assert sleep_started.wait(timeout=1)
thread = getattr(SystemHelper, monitor_attr)
SystemHelper._start_graceful_shutdown_monitor()
assert getattr(SystemHelper, monitor_attr) is thread
release_sleep.set()
thread.join(timeout=1)
assert thread.is_alive() is False
assert getattr(SystemHelper, monitor_attr) is None
restart.assert_called_once_with()
finally:
release_sleep.set()
if thread is not None:
thread.join(timeout=1)
setattr(SystemHelper, monitor_attr, original_monitor)
def test_graceful_shutdown_monitor_releases_owner_when_thread_start_fails(monkeypatch):
"""兜底线程启动失败时必须释放 owner,允许后续请求重试。"""
monitor_attr = "_SystemHelper__graceful_shutdown_monitor"
original_monitor = getattr(SystemHelper, monitor_attr)
class FailingThread:
"""模拟在登记 owner 后启动失败的线程对象。"""
def __init__(self, **_kwargs):
"""接收真实 Thread 构造参数,但不创建系统线程。"""
def start(self):
"""模拟底层线程资源不足导致的启动失败。"""
raise RuntimeError("thread start failed")
setattr(SystemHelper, monitor_attr, None)
try:
monkeypatch.setattr("app.runtime.state.threading.Thread", FailingThread)
with pytest.raises(RuntimeError, match="thread start failed"):
SystemHelper._start_graceful_shutdown_monitor()
assert getattr(SystemHelper, monitor_attr) is None
finally:
setattr(SystemHelper, monitor_attr, original_monitor)
def test_execute_with_subprocess_passes_env_to_subprocess(): def test_execute_with_subprocess_passes_env_to_subprocess():
with patch("app.adapters.system.host.subprocess.run") as run_mock: with patch("app.adapters.system.host.subprocess.run") as run_mock:
run_mock.return_value.stdout = "ok" run_mock.return_value.stdout = "ok"