refactor: unify doh executor shutdown

This commit is contained in:
jxxghp
2026-08-24 15:49:19 +08:00
parent e9053a6562
commit 41b1460b60
9 changed files with 256 additions and 101 deletions
+47 -20
View File
@@ -3,25 +3,26 @@ doh函数的实现。
author: https://github.com/C5H12O5/syno-videoinfo-plugin author: https://github.com/C5H12O5/syno-videoinfo-plugin
""" """
import base64 import base64
import concurrent
import concurrent.futures
import json import json
import socket import socket
import struct import struct
import urllib import urllib
import urllib.request import urllib.request
from concurrent.futures import as_completed
from threading import Lock from threading import Lock
from typing import Dict, Optional from typing import Dict, Optional
from app.foundation.singleton import Singleton
from app.runtime.execution import OwnedThreadPoolExecutor
from app.runtime.log import logger from app.runtime.log import logger
from app.runtime.reload import ConfigReloadMixin from app.runtime.reload import ConfigReloadMixin
from app.runtime.settings import get_runtime_setting from app.runtime.settings import get_runtime_setting
from app.foundation.singleton import Singleton
# DoH 关闭时需要释放线程池;保持惰性创建可避免未启用 DoH 时占用进程级资源 # DoH 关闭时需要释放线程池;保持惰性创建可避免未启用 DoH 时占用进程级资源
_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None _executor: Optional[OwnedThreadPoolExecutor] = None
_executor_lock = Lock() _executor_lock = Lock()
_doh_enabled = False _doh_enabled = False
_DOH_EXECUTOR_STOP_TIMEOUT_SECONDS = 10.0
# 定义默认的DoH配置 # 定义默认的DoH配置
_doh_timeout = 5 _doh_timeout = 5
@@ -36,20 +37,23 @@ def _doh_setting(key: str):
return get_runtime_setting(key) return get_runtime_setting(key)
def _get_executor_locked() -> concurrent.futures.ThreadPoolExecutor: def _get_executor_locked() -> OwnedThreadPoolExecutor:
"""在持有执行器锁时按需获取 DoH 查询线程池""" """在持有执行器锁时按需获取 DoH 查询线程池"""
global _executor global _executor
if _executor is None: if _executor is None:
_executor = concurrent.futures.ThreadPoolExecutor() _executor = OwnedThreadPoolExecutor()
return _executor return _executor
def enable_doh(enable: bool) -> None: def enable_doh(enable: bool) -> bool:
""" """
对 socket.getaddrinfo 进行补丁 对 socket.getaddrinfo 进行补丁
:param enable: 是否启用 DoH 解析
:return: 状态切换成功时返回 True;旧 executor 未收敛时返回 False
""" """
global _doh_enabled global _doh_enabled, _executor
def _patched_getaddrinfo(host: str, *args, **kwargs): def _patched_getaddrinfo(host: str, *args, **kwargs):
""" """
@@ -73,19 +77,31 @@ def enable_doh(enable: bool) -> None:
executor.submit(_doh_query, resolver, host) executor.submit(_doh_query, resolver, host)
for resolver in _doh_setting("DOH_RESOLVERS").split(",") for resolver in _doh_setting("DOH_RESOLVERS").split(",")
] ]
for future in concurrent.futures.as_completed(futures): for future in as_completed(futures):
ip = future.result() ip = future.result()
if ip is not None: if ip is not None:
logger.info(f"已解析 [{host}] 为 [{ip}]") logger.info(f"已解析 [{host}] 为 [{ip}]")
with _doh_lock: # 关闭可能在查询等待期间恢复系统 DNS;关闭后的结果不得回填到下一轮配置。
_doh_cache[host] = ip with _executor_lock:
cache_allowed = _doh_enabled
if cache_allowed:
with _doh_lock:
_doh_cache[host] = ip
host = ip host = ip
break break
return _orig_getaddrinfo(host, *args, **kwargs) return _orig_getaddrinfo(host, *args, **kwargs)
with _executor_lock: with _executor_lock:
if enable and _executor is not None and not _executor.accepting:
# 上一轮 shutdown 超时后必须继续持有原 owner;只有真实收敛才能替换执行器。
if not _executor.shutdown_bounded(timeout=0.0):
_doh_enabled = False
socket.getaddrinfo = _orig_getaddrinfo
return False
_executor = None
_doh_enabled = enable _doh_enabled = enable
socket.getaddrinfo = _patched_getaddrinfo if enable else _orig_getaddrinfo socket.getaddrinfo = _patched_getaddrinfo if enable else _orig_getaddrinfo
return True
class DohHelper(ConfigReloadMixin, metaclass=Singleton): class DohHelper(ConfigReloadMixin, metaclass=Singleton):
@@ -101,29 +117,40 @@ class DohHelper(ConfigReloadMixin, metaclass=Singleton):
def on_config_changed(self) -> None: def on_config_changed(self) -> None:
"""配置变化时清理缓存并重新应用 DoH 状态。""" """配置变化时清理缓存并重新应用 DoH 状态。"""
if not _doh_setting("DOH_ENABLE"): if not _doh_setting("DOH_ENABLE"):
self.shutdown() if not self.shutdown():
logger.error("DoH配置关闭后查询线程池未在预算内收敛")
return return
with _doh_lock: with _doh_lock:
# DOH配置有变动的情况下,清空缓存 # DOH配置有变动的情况下,清空缓存
_doh_cache.clear() _doh_cache.clear()
enable_doh(True) if not enable_doh(True):
logger.error("DoH查询线程池尚未收敛,暂不重新启用")
def get_reload_name(self) -> str: def get_reload_name(self) -> str:
"""返回 DoH 配置重载名称。""" """返回 DoH 配置重载名称。"""
return 'DoH' return 'DoH'
def shutdown(self) -> None: def shutdown(
"""恢复系统 DNS 并释放 DoH 查询线程池""" self,
timeout: float = _DOH_EXECUTOR_STOP_TIMEOUT_SECONDS,
) -> bool:
"""
恢复系统 DNS 并有限等待 DoH 查询线程池。
:param timeout: 等待已接受查询和 worker 终止的最长秒数
:return: 查询线程池真实终止时返回 True,否则返回 False
"""
global _executor, _doh_enabled global _executor, _doh_enabled
with _executor_lock: with _executor_lock:
_doh_enabled = False _doh_enabled = False
socket.getaddrinfo = _orig_getaddrinfo socket.getaddrinfo = _orig_getaddrinfo
executor = _executor executor = _executor
_executor = None converged = executor is None or executor.shutdown_bounded(timeout=timeout)
if converged and _executor is executor:
_executor = None
with _doh_lock: with _doh_lock:
_doh_cache.clear() _doh_cache.clear()
if executor: return converged
executor.shutdown(wait=True)
def _doh_query(resolver: str, host: str) -> Optional[str]: def _doh_query(resolver: str, host: str) -> Optional[str]:
+91 -1
View File
@@ -1,15 +1,105 @@
import asyncio import asyncio
import inspect import inspect
import threading
import time import time
from concurrent.futures import Future, ThreadPoolExecutor, wait
from contextvars import copy_context from contextvars import copy_context
from functools import partial, wraps from functools import partial, wraps
from typing import Any, Callable, TypeVar from typing import Any, Callable, TypeVar, cast
from app.schemas.exception import ImmediateException from app.schemas.exception import ImmediateException
from anyio.to_thread import run_sync from anyio.to_thread import run_sync
TaskResult = TypeVar("TaskResult") TaskResult = TypeVar("TaskResult")
ExecutorResult = TypeVar("ExecutorResult")
class OwnedThreadPoolExecutor(ThreadPoolExecutor):
"""
追踪已接受 Future,并提供可重试的有界关闭合同。
该 owner 不取消排队任务,保持 ``ThreadPoolExecutor.shutdown(wait=True)``
的历史完成语义;区别仅在于调用方可以在预算耗尽后保留同一实例继续收敛。
"""
def __init__(self, max_workers: int | None = None) -> None:
"""初始化线程池、提交准入状态和 Future owner 集合。"""
super().__init__(max_workers=max_workers)
self._ownership_lock = threading.RLock()
self._accepting = True
self._owned_futures: set[Future[Any]] = set()
@property
def accepting(self) -> bool:
"""返回执行器是否仍允许提交新任务。"""
with self._ownership_lock:
return self._accepting
def submit(
self,
fn: Callable[..., ExecutorResult],
/,
*args: Any,
**kwargs: Any,
) -> Future[ExecutorResult]:
"""提交任务并在其达到终态前保留 owner。"""
with self._ownership_lock:
future = super().submit(fn, *args, **kwargs)
self._owned_futures.add(future)
future.add_done_callback(self._discard_future)
return future
def shutdown(
self,
wait: bool = True,
*,
cancel_futures: bool = False,
) -> None:
"""封口提交准入并保留标准库 shutdown 的调用语义。"""
with self._ownership_lock:
self._accepting = False
# 先在锁内封口;真正等待必须在锁外进行,否则 worker 的完成回调无法释放 owner。
super().shutdown(wait=False, cancel_futures=cancel_futures)
if wait:
super().shutdown(wait=True, cancel_futures=cancel_futures)
def _discard_future(self, future: Future[Any]) -> None:
"""任务达到终态后释放 owner 记录。"""
with self._ownership_lock:
self._owned_futures.discard(future)
def shutdown_bounded(self, timeout: float) -> bool:
"""
封口新提交并有限等待全部已接受任务。
:param timeout: 等待 Future 达到终态的最长秒数
:return: 所有任务与 worker 均已终止时返回 True,否则返回 False
"""
deadline = time.monotonic() + max(0.0, timeout)
with self._ownership_lock:
self._accepting = False
# 不取消排队工作,保持历史 shutdown(wait=True) 的完成语义。
super().shutdown(wait=False)
owned_futures = tuple(self._owned_futures)
if owned_futures:
_, pending_futures = wait(
owned_futures,
timeout=max(0.0, deadline - time.monotonic()),
)
if pending_futures:
return False
# 标准库只提供无界 wait=True;Future 又会先标记完成再执行 done callback
# 因此封口后读取稳定 worker 集合,复用同一 deadline 做有限 join。
worker_threads = tuple(cast(set[threading.Thread], self._threads))
current_thread = threading.current_thread()
for worker_thread in worker_threads:
if worker_thread is current_thread:
continue
worker_thread.join(
timeout=max(0.0, deadline - time.monotonic()),
)
return all(not worker_thread.is_alive() for worker_thread in worker_threads)
async def await_task_to_terminal( async def await_task_to_terminal(
+8 -67
View File
@@ -1,10 +1,9 @@
import threading from concurrent.futures import Future
import time
from concurrent.futures import Future, ThreadPoolExecutor, wait
from contextvars import copy_context from contextvars import copy_context
from typing import Any, Callable, TypeVar, cast from typing import Any, Callable, TypeVar, cast
from app.foundation.singleton import Singleton from app.foundation.singleton import Singleton
from app.runtime.execution import OwnedThreadPoolExecutor
from app.runtime.settings import RuntimeSettingsCompat from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat() settings = RuntimeSettingsCompat()
@@ -12,67 +11,8 @@ settings = RuntimeSettingsCompat()
_Result = TypeVar("_Result") _Result = TypeVar("_Result")
_THREAD_POOL_STOP_TIMEOUT_SECONDS = 10.0 _THREAD_POOL_STOP_TIMEOUT_SECONDS = 10.0
# 阶段 64 曾在本模块引入该私有名;保留精确别名,避免测试或外部诊断代码失效。
class _OwnedThreadPoolExecutor(ThreadPoolExecutor): _OwnedThreadPoolExecutor = OwnedThreadPoolExecutor
"""
追踪所有提交入口的共享执行器,包括旧调用方直接使用的 ``pool.submit``。
"""
def __init__(self, max_workers: int) -> None:
"""初始化线程池及其 Future owner 集合。"""
super().__init__(max_workers=max_workers)
self._ownership_lock = threading.RLock()
self._owned_futures: set[Future[Any]] = set()
def submit(
self,
fn: Callable[..., _Result],
/,
*args: Any,
**kwargs: Any,
) -> Future[_Result]:
"""提交任务并在其达到终态前保留 owner。"""
with self._ownership_lock:
future = super().submit(fn, *args, **kwargs)
self._owned_futures.add(future)
future.add_done_callback(self._discard_future)
return future
def _discard_future(self, future: Future[Any]) -> None:
"""任务达到终态后释放 owner 记录。"""
with self._ownership_lock:
self._owned_futures.discard(future)
def shutdown_bounded(self, timeout: float) -> bool:
"""
封口新提交并有限等待全部已接受任务。
:param timeout: 等待 Future 达到终态的最长秒数
:return: 所有任务与 worker 均已终止时返回 True,否则返回 False
"""
deadline = time.monotonic() + max(0.0, timeout)
with self._ownership_lock:
# 不取消排队工作,保持历史 shutdown(wait=True) 的完成语义。
super().shutdown(wait=False)
owned_futures = tuple(self._owned_futures)
if owned_futures:
_, pending_futures = wait(
owned_futures,
timeout=max(0.0, deadline - time.monotonic()),
)
if pending_futures:
return False
# 标准库只提供无界 wait=True;Future 又会先标记完成再执行 done callback
# 因此封口后读取稳定 worker 集合,复用同一 deadline 做有限 join。
worker_threads = tuple(cast(set[threading.Thread], self._threads))
current_thread = threading.current_thread()
for worker_thread in worker_threads:
if worker_thread is current_thread:
continue
worker_thread.join(
timeout=max(0.0, deadline - time.monotonic()),
)
return all(not worker_thread.is_alive() for worker_thread in worker_threads)
# strict mypy 跳过 foundation 实现导入,因此无法在本文件解析既有 Singleton 元类类型。 # strict mypy 跳过 foundation 实现导入,因此无法在本文件解析既有 Singleton 元类类型。
@@ -83,7 +23,7 @@ class ThreadHelper(metaclass=Singleton): # type: ignore[metaclass]
def __init__(self) -> None: def __init__(self) -> None:
"""按系统配置创建共享后台线程池。""" """按系统配置创建共享后台线程池。"""
self.pool = _OwnedThreadPoolExecutor(max_workers=settings.CONF.threadpool) self.pool = OwnedThreadPoolExecutor(max_workers=settings.CONF.threadpool)
def submit( def submit(
self, self,
@@ -99,7 +39,8 @@ class ThreadHelper(metaclass=Singleton): # type: ignore[metaclass]
:return: future :return: future
""" """
context = copy_context() context = copy_context()
return self.pool.submit(context.run, func, *args, **kwargs) # strict mypy 跳过 execution 实现导入,需要在兼容门面恢复 Future 泛型。
return cast(Future[_Result], self.pool.submit(context.run, func, *args, **kwargs))
def shutdown( def shutdown(
self, self,
@@ -111,4 +52,4 @@ class ThreadHelper(metaclass=Singleton): # type: ignore[metaclass]
:param timeout: 等待已接受任务达到终态的最长秒数 :param timeout: 等待已接受任务达到终态的最长秒数
:return: 全部任务和 worker 均已终止时返回 True,否则返回 False :return: 全部任务和 worker 均已终止时返回 True,否则返回 False
""" """
return self.pool.shutdown_bounded(timeout=timeout) return bool(self.pool.shutdown_bounded(timeout=timeout))
@@ -2,13 +2,13 @@
> 文档性质:当前架构复核、优秀 Python 后端实践对标、AI 可执行任务手册 > 文档性质:当前架构复核、优秀 Python 后端实践对标、AI 可执行任务手册
> 适用仓库:`MoviePilot`,分支 `v3` > 适用仓库:`MoviePilot`,分支 `v3`
> 审计基线:`88262191`2026-08-24 > 审计基线:`e9053a65`2026-08-24
> 审计范围:宿主后端;排除 `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 已补齐异步防抖取消的终态所有权;阶段 41 已统一优雅重启兜底线程的唯一所有权;阶段 42 已补齐 Telegram typing 的多实例隔离和终态 owner;阶段 43 已统一 Discord typing 的异步 owner 和 shutdown 收尾;阶段 44 已清除 WebAgent 测试临时事件循环提前关闭产生的 CI 红注解;阶段 45 已统一影视与字幕搜索的请求级逐页任务编排;阶段 46 已收口启动性能门禁的托管 runner 假失败与诊断输出;阶段 47 已补齐 Agent 渠道流式刷新任务的重入 owner;阶段 48 已统一工件上传 action 的 Node 24 主版本;阶段 49 已统一插件安装的同步/异步代际解析事实源;阶段 50 已统一插件市场 GitHub 请求降级策略;阶段 51 已统一插件索引请求与响应三态策略;阶段 52 已统一插件 Release 分页策略;阶段 53 已统一远端插件安装模式决策;阶段 54 已补齐同步安装成功后的临时回滚备份清理;阶段 55~56 已收口官方插件观察基线与报告保留策略;阶段 57 已统一进程级运行时 Facade 门禁并补齐 ModuleManager 边界;阶段 58 已消除 AgentTask 关闭回归的跨线程零时长等待竞态;阶段 59 已统一 Feishu 多实例长连接的 SDK 循环路由;阶段 60 已清除命令服务虚假的关停 owner 声明;阶段 61 已统一 Capability Runtime 同步/异步关闭的诚实收敛结果;阶段 62 已统一消息渠道长连接的多实例关闭收敛合同;阶段 63 已补齐应用消息队列线程的关闭收敛合同;阶段 64 已统一共享线程池的有界关闭 owner;阶段 65 已统一异步文件日志的单一有界写入和关闭 owner。 > 实施进度:阶段 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 已统一优雅重启兜底线程的唯一所有权;阶段 42 已补齐 Telegram typing 的多实例隔离和终态 owner;阶段 43 已统一 Discord typing 的异步 owner 和 shutdown 收尾;阶段 44 已清除 WebAgent 测试临时事件循环提前关闭产生的 CI 红注解;阶段 45 已统一影视与字幕搜索的请求级逐页任务编排;阶段 46 已收口启动性能门禁的托管 runner 假失败与诊断输出;阶段 47 已补齐 Agent 渠道流式刷新任务的重入 owner;阶段 48 已统一工件上传 action 的 Node 24 主版本;阶段 49 已统一插件安装的同步/异步代际解析事实源;阶段 50 已统一插件市场 GitHub 请求降级策略;阶段 51 已统一插件索引请求与响应三态策略;阶段 52 已统一插件 Release 分页策略;阶段 53 已统一远端插件安装模式决策;阶段 54 已补齐同步安装成功后的临时回滚备份清理;阶段 55~56 已收口官方插件观察基线与报告保留策略;阶段 57 已统一进程级运行时 Facade 门禁并补齐 ModuleManager 边界;阶段 58 已消除 AgentTask 关闭回归的跨线程零时长等待竞态;阶段 59 已统一 Feishu 多实例长连接的 SDK 循环路由;阶段 60 已清除命令服务虚假的关停 owner 声明;阶段 61 已统一 Capability Runtime 同步/异步关闭的诚实收敛结果;阶段 62 已统一消息渠道长连接的多实例关闭收敛合同;阶段 63 已补齐应用消息队列线程的关闭收敛合同;阶段 64 已统一共享线程池的有界关闭 owner;阶段 65 已统一异步文件日志的单一有界写入和关闭 owner;阶段 66 已统一 DoH 与共享线程池的有界 executor owner。
> 当前 canonical 状态:API/Application 公共复杂度基线已清零,组合根外 `SystemConfigOper()` 构造和 Model/Oper 隐式事务均为 0;命名 Chain/Agent 数据端口、TaskRegistry owner、Module Contract V2、typed Event、Outbox durable intent、请求关联和插件运行时 getter 已形成当前路径。插件仓适配、未知第三方 fallback 和其它 E1/E3 副作用仍按风险持续治理。 > 当前 canonical 状态:API/Application 公共复杂度基线已清零,组合根外 `SystemConfigOper()` 构造和 Model/Oper 隐式事务均为 0;命名 Chain/Agent 数据端口、TaskRegistry owner、Module Contract V2、typed Event、Outbox durable intent、请求关联和插件运行时 getter 已形成当前路径。插件仓适配、未知第三方 fallback 和其它 E1/E3 副作用仍按风险持续治理。
> 最新阶段:阶段 65 已统一异步文件日志的单一有界写入和关闭 owner。 > 最新阶段:阶段 66 已统一 DoH 与共享线程池的有界 executor owner。
## 当前复核结论(2026-08-24 ## 当前复核结论(2026-08-24
@@ -17,7 +17,7 @@
### 长期整改阶段 0:治理门禁恢复(2026-08-23 ### 长期整改阶段 0:治理门禁恢复(2026-08-23
- 宿主依赖基线已审查 TaskRegistry、有界后台 owner 与插件变更准入接入后的语义差异:当前为 `810` 个模块、`6560` 条内部导入边,12 组重点禁止边继续全部为 `0`,唯一非平凡 SCC 仍是隔离的 TMDB 移植包。 - 宿主依赖基线已审查 TaskRegistry、有界后台 owner 与插件变更准入接入后的语义差异:当前为 `810` 个模块、`6562` 条内部导入边,12 组重点禁止边继续全部为 `0`,唯一非平凡 SCC 仍是隔离的 TMDB 移植包。
- 启动性能探针会在隔离生命周期中真实创建并释放 TaskRegistrynormal/safe 组件数分别为 `23`/`11`,CI 只读检查使用稳定的宿主模块集合和生命周期组件顺序,不再把 Python/平台模块数量当作硬合同。 - 启动性能探针会在隔离生命周期中真实创建并释放 TaskRegistrynormal/safe 组件数分别为 `23`/`11`,CI 只读检查使用稳定的宿主模块集合和生命周期组件顺序,不再把 Python/平台模块数量当作硬合同。
- 官方插件快照覆盖 `plugins.v3``plugins.v2` 以及 V3 实际会从 `package.json` 回退加载的 31 个默认实现;`app/plugins/**` 仍只是宿主运行副本,不进入扫描。 - 官方插件快照覆盖 `plugins.v3``plugins.v2` 以及 V3 实际会从 `package.json` 回退加载的 31 个默认实现;`app/plugins/**` 仍只是宿主运行副本,不进入扫描。
- SDK 快照以各模块显式 `__all__` 为公开合同,能够记录赋值别名;`typing``__future__` 等实现期导入不再被误冻结,既有数据库备份门面已补精确导出清单。 - SDK 快照以各模块显式 `__all__` 为公开合同,能够记录赋值别名;`typing``__future__` 等实现期导入不再被误冻结,既有数据库备份门面已补精确导出清单。
@@ -707,6 +707,26 @@
日志/生命周期/旧导入专项 83 项、架构合同 94 项、兼容调用链 102 项、Pylint 10.00/10、strict mypy 日志/生命周期/旧导入专项 83 项、架构合同 94 项、兼容调用链 102 项、Pylint 10.00/10、strict mypy
41 文件、全部宿主与质量 ratchet 及四分片全量 `5952 passed, 3 skipped` 均通过。 41 文件、全部宿主与质量 ratchet 及四分片全量 `5952 passed, 3 skipped` 均通过。
### 长期整改阶段 66:DoH 与共享线程池有界 executor owner 统一(2026-08-24
- DoH 仍单独创建标准库 `ThreadPoolExecutor`,关闭时先清空全局句柄,再执行无界 `shutdown(wait=True)`
任一底层 DNS/HTTPS 调用未返回都会阻塞 `stop_modules()` 所在事件循环,外层生命周期预算无法介入,
且已丢失的 executor 无法重试收敛。阻塞探针确认旧关闭 50ms 内不返回,同时 owner 已从全局移除。
- 阶段 64 的 Future 追踪和有界 worker join 已抽取为
`app.runtime.execution.OwnedThreadPoolExecutor` 唯一实现;`ThreadHelper` 与 DoH 共同复用,旧私有类名
保留精确别名。标准 `shutdown(wait=True)` 仍可用于旧 `.pool` 调用,但采用锁内封口、锁外等待,避免
worker 完成回调与 owner 锁互锁。
- DoH 关闭现在先恢复系统 DNS,再使用 10 秒默认预算有限等待;超时返回 `False` 并保留已封口 executor
startup 继续收口其它资源并向上报告失败。释放阻塞查询后可在同一 owner 上重试成功,只有真实终止后
才清空句柄;配置重启也不得覆盖未收敛 owner,关闭期间完成的旧查询不会回填下一轮缓存。
- `DohHelper()``enable_doh()`、无参数 `shutdown()`、socket 补丁、惰性建池、解析器并发、缓存命中及
`app.helper.doh` 精确映射全部保留;新增 timeout 和布尔收敛结果为向后兼容扩展。未修改 SDK/Compat
清单、插件 Hook 或插件仓,V1/V2/V3 插件导入和调用边界不变。
- 阻塞查询、有限返回、owner 保留、拒绝替换、重试终态、标准 shutdown 兼容和 startup 失败传播均有
故障注入;DoH/线程池/生命周期专项 66 项、旧导入与插件兼容联合专项 174 项、架构合同 94 项、
Pylint 10.00/10、strict mypy 41 文件、全部宿主与质量 ratchet 及四分片全量
`5955 passed, 3 skipped` 均通过。
### 总体判断 ### 总体判断
当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**: 当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**:
+3
View File
@@ -201,6 +201,9 @@ ModuleManager 与 startup 组合根继续关闭其余资源但必须向上返回
并向 startup 返回 `False`,不得用无界 `join()` 阻塞生命周期或把日志当作成功。 并向 startup 返回 `False`,不得用无界 `join()` 阻塞生命周期或把日志当作成功。
共享 `ThreadHelper` 必须追踪通过宿主 `submit()` 和旧兼容 `.pool.submit()` 接受的全部 Future;关闭时 共享 `ThreadHelper` 必须追踪通过宿主 `submit()` 和旧兼容 `.pool.submit()` 接受的全部 Future;关闭时
先封口新任务,再有限等待且保留未终止 owner,结果由 startup 聚合,不得恢复无界 executor shutdown。 先封口新任务,再有限等待且保留未终止 owner,结果由 startup 聚合,不得恢复无界 executor shutdown。
`app.runtime.execution.OwnedThreadPoolExecutor` 是进程级同步执行器有界收敛的唯一事实源;新的专用
线程池不得复制 Future 追踪、worker join 或重试关闭实现。DoH 查询线程池也必须复用该 owner:恢复系统
DNS 后有限等待,超时保留原 executor 并向 startup 返回 `False`,真实收敛前不得创建替代线程池或回填缓存。
协程环境文件日志属于有界 E1 观测能力,只允许单一队列 writer;队列满时不得再以无界 executor 协程环境文件日志属于有界 E1 观测能力,只允许单一队列 writer;队列满时不得再以无界 executor
形成第二条异步写入路径。日志关闭必须有限等待 writer 与文件处理器,未收敛时 `LoggerManager` 形成第二条异步写入路径。日志关闭必须有限等待 writer 与文件处理器,未收敛时 `LoggerManager`
保留原 owner 并让 lifespan 以关闭失败结束,不得先清空引用或用无界 `join()` 掩盖失败。 保留原 owner 并让 lifespan 以关闭失败结束,不得先清空引用或用无界 `join()` 掩盖失败。
+4 -2
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [], "runtime_to_db": [],
"workflow_to_db": [] "workflow_to_db": []
}, },
"edge_count": 6560, "edge_count": 6562,
"edge_sha256": "bdd34affb7c42a4cbcdc85e713d9b4ac591c3343559dd5032b4518e77eba721e", "edge_sha256": "08b6e0005a3c316a1de675193f7749ff8a93e8c1c052eb48339aa58b308ad6bf",
"edges": [ "edges": [
"app -> app.runtime", "app -> app.runtime",
"app -> app.runtime.compat", "app -> app.runtime.compat",
@@ -109,6 +109,7 @@
"app.adapters.network.doh -> app.foundation", "app.adapters.network.doh -> app.foundation",
"app.adapters.network.doh -> app.foundation.singleton", "app.adapters.network.doh -> app.foundation.singleton",
"app.adapters.network.doh -> app.runtime", "app.adapters.network.doh -> app.runtime",
"app.adapters.network.doh -> app.runtime.execution",
"app.adapters.network.doh -> app.runtime.log", "app.adapters.network.doh -> app.runtime.log",
"app.adapters.network.doh -> app.runtime.reload", "app.adapters.network.doh -> app.runtime.reload",
"app.adapters.network.doh -> app.runtime.settings", "app.adapters.network.doh -> app.runtime.settings",
@@ -5810,6 +5811,7 @@
"app.runtime.thread -> app.foundation", "app.runtime.thread -> app.foundation",
"app.runtime.thread -> app.foundation.singleton", "app.runtime.thread -> app.foundation.singleton",
"app.runtime.thread -> app.runtime", "app.runtime.thread -> app.runtime",
"app.runtime.thread -> app.runtime.execution",
"app.runtime.thread -> app.runtime.settings", "app.runtime.thread -> app.runtime.settings",
"app.scheduler -> app.adapters", "app.scheduler -> app.adapters",
"app.scheduler -> app.adapters.external", "app.scheduler -> app.adapters.external",
+56 -7
View File
@@ -1,6 +1,9 @@
import socket import socket
import threading
import time
from app.adapters.network import doh from app.adapters.network import doh
from app.runtime.execution import OwnedThreadPoolExecutor
def test_doh_executor_is_lazy_and_shutdown_restores_socket(monkeypatch): def test_doh_executor_is_lazy_and_shutdown_restores_socket(monkeypatch):
@@ -13,15 +16,15 @@ def test_doh_executor_is_lazy_and_shutdown_restores_socket(monkeypatch):
monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: []) monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: [])
try: try:
helper.shutdown() assert helper.shutdown() is True
assert doh._executor is None assert doh._executor is None
doh.enable_doh(True) assert doh.enable_doh(True) is True
socket.getaddrinfo("example.com", None) socket.getaddrinfo("example.com", None)
executor = doh._executor executor = doh._executor
assert executor is not None assert isinstance(executor, OwnedThreadPoolExecutor)
helper.shutdown() assert helper.shutdown() is True
assert doh._executor is None assert doh._executor is None
assert socket.getaddrinfo is doh._orig_getaddrinfo assert socket.getaddrinfo is doh._orig_getaddrinfo
@@ -31,6 +34,52 @@ def test_doh_executor_is_lazy_and_shutdown_restores_socket(monkeypatch):
socket.getaddrinfo = original_getaddrinfo socket.getaddrinfo = original_getaddrinfo
def test_doh_shutdown_is_bounded_and_retryable(monkeypatch):
"""阻塞查询超时时保留同一 owner,释放后可重试并安全重新启用。"""
original_getaddrinfo = socket.getaddrinfo
helper = object.__new__(doh.DohHelper)
entered = threading.Event()
release = threading.Event()
future = None
monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: [])
def blocked_query() -> None:
"""模拟底层网络栈未按 DoH 请求超时返回的同步查询。"""
entered.set()
release.wait()
try:
assert helper.shutdown(timeout=1) is True
assert doh.enable_doh(True) is True
with doh._executor_lock:
executor = doh._get_executor_locked()
future = executor.submit(blocked_query)
assert entered.wait(timeout=1)
started_at = time.monotonic()
assert helper.shutdown(timeout=0.01) is False
assert time.monotonic() - started_at < 1
assert doh._executor is executor
assert socket.getaddrinfo is doh._orig_getaddrinfo
assert executor.accepting is False
# 未收敛 owner 不得被新 executor 覆盖,否则旧查询会脱离生命周期追踪。
assert doh.enable_doh(True) is False
assert doh._executor is executor
release.set()
future.result(timeout=1)
assert helper.shutdown(timeout=1) is True
assert doh._executor is None
assert doh.enable_doh(True) is True
finally:
release.set()
if future is not None:
future.result(timeout=1)
helper.shutdown(timeout=1)
socket.getaddrinfo = original_getaddrinfo
def test_doh_config_reload_disables_and_closes_executor(monkeypatch): def test_doh_config_reload_disables_and_closes_executor(monkeypatch):
"""热更新关闭 DoH 时恢复系统 DNS 并释放已创建的线程池""" """热更新关闭 DoH 时恢复系统 DNS 并释放已创建的线程池"""
original_getaddrinfo = socket.getaddrinfo original_getaddrinfo = socket.getaddrinfo
@@ -41,8 +90,8 @@ def test_doh_config_reload_disables_and_closes_executor(monkeypatch):
monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: []) monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: [])
try: try:
helper.shutdown() assert helper.shutdown() is True
doh.enable_doh(True) assert doh.enable_doh(True) is True
socket.getaddrinfo("example.com", None) socket.getaddrinfo("example.com", None)
executor = doh._executor executor = doh._executor
assert executor is not None assert executor is not None
@@ -83,7 +132,7 @@ def test_enable_doh_reuses_cached_host_resolution(monkeypatch):
doh._doh_cache.clear() doh._doh_cache.clear()
try: try:
doh.enable_doh(True) assert doh.enable_doh(True) is True
socket.getaddrinfo("example.com", None) socket.getaddrinfo("example.com", None)
socket.getaddrinfo("example.com", None) socket.getaddrinfo("example.com", None)
+12
View File
@@ -1001,6 +1001,18 @@ def test_stop_modules_propagates_shared_thread_pool_nonconvergence(monkeypatch):
_assert_completed_once(dependency) _assert_completed_once(dependency)
def test_stop_modules_propagates_doh_nonconvergence(monkeypatch):
"""DoH 查询线程未终止时必须由模块服务关闭结果向上暴露。"""
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
dependencies["doh"].return_value = False
converged = asyncio.run(modules_initializer.stop_modules())
assert converged is False
for dependency in dependencies.values():
_assert_completed_once(dependency)
def test_stop_modules_drains_web_agent_tasks_before_persistence(monkeypatch): def test_stop_modules_drains_web_agent_tasks_before_persistence(monkeypatch):
"""关闭时先收口 Web Agent,再关闭持久化准入和数据库任务。""" """关闭时先收口 Web Agent,再关闭持久化准入和数据库任务。"""
order = [] order = []
+11
View File
@@ -101,3 +101,14 @@ def test_thread_helper_shutdown_waits_for_worker_after_future_completion():
callback_release.set() callback_release.set()
assert executor.shutdown_bounded(timeout=1) is True assert executor.shutdown_bounded(timeout=1) is True
def test_owned_executor_preserves_standard_shutdown_contract():
"""旧调用方直接使用 pool.shutdown(wait=True) 时不得与 owner 回调互锁。"""
executor = thread_module._OwnedThreadPoolExecutor(max_workers=1)
future = executor.submit(lambda: "done")
executor.shutdown(wait=True)
assert future.result(timeout=1) == "done"
assert executor.accepting is False