"""微信 iLink(ClawBot)渠道插件生命周期钩子处理。 实现 ``LifecycleHookHandler`` Protocol,管理插件级资源(httpx 连接池), 响应宿主生命周期状态变迁。 设计要点: - LifecycleHandler 为单实例(非 per-account),可持有 mutable 状态用于 资源管理(与适配器的 INV-5 无 mutable 状态约束不同)。 - httpx 连接池在 ``onInit`` 创建并注入 ``ILinkClient``,``onUnload`` 解除 引用后关闭;连接数上限对齐 manifest.json 中 ``resource_quota.max_connections=10``。 - 长轮询拉取由 ``StreamWorker`` 通过 ``PullerAdapter`` 统一管理, ``LifecycleHandler`` 不持有长轮询句柄。 - ``onReconfigure`` 校验不可热更新字段,变更时抛 ``ConfigRestartRequiredError``, 校验失败保持原基线以支持宿主回滚。 - ``onFail`` 不抛异常、不静默吞错;logger 失败时回退到 ``sys.stderr.write``, 不阻断 FR-36 优雅降级流程。 依赖方向:仅 import ``yuxi.channels.contract.*`` + 标准库 + httpx, 不污染框架层。 """ from __future__ import annotations import sys from typing import TYPE_CHECKING, Any import httpx from yuxi.channels.contract.dtos.config import ConfigField from yuxi.channels.contract.errors import ConfigRestartRequiredError from yuxi.channels.contract.ports.driven.cache_port import CachePort from yuxi.channels.contract.ports.driven.config_port import ConfigPort from yuxi.channels.contract.ports.driven.logger_port import LoggerPort if TYPE_CHECKING: from .ilink_client import ILinkClient from ._constants import ( DEFAULT_MAX_CONNECTIONS, DEFAULT_MAX_KEEPALIVE_CONNECTIONS, DRAIN_TIMEOUT_SECONDS, HTTP_TIMEOUT_SECONDS, ) # CachePort 失效模式(清空插件所有运行时缓存,避免孤儿键残留) _CACHE_INVALIDATE_PATTERN = "wechat_ilink:*" class WeChatILinkLifecycleHandler: """微信 iLink 渠道插件生命周期钩子处理。 单实例,管理插件级资源生命周期。``_http_client`` 为共享连接池,通过 ``attach_http_client`` 注入 ``ILinkClient`` 供其复用(避免每请求新建 TCP 连接);``onReconfigure`` 通过入参 ``old_config`` 做差异化校验, 不再自行维护基线缓存。长轮询连接由 ``StreamWorker`` 管理,本处理器不持有拉取句柄。 """ def __init__( self, config_port: ConfigPort, logger_port: LoggerPort, cache_port: CachePort, config_schema: tuple[ConfigField, ...], client: ILinkClient | None = None, max_connections: int = DEFAULT_MAX_CONNECTIONS, ) -> None: self._config = config_port self._logger = logger_port self._cache = cache_port self._client = client self._max_connections = max_connections self._http_client: httpx.AsyncClient | None = None self._started: bool = False # 从 config_schema 派生不可热更新键(hot_reloadable=False 的字段 key) self._restart_required_keys: tuple[str, ...] = tuple( field.key for field in config_schema if not field.hot_reloadable ) # ------------------------------------------------------------------ # LifecycleHookHandler Protocol 实现 # ------------------------------------------------------------------ async def onInit(self) -> None: """初始化资源:创建 httpx 连接池并注入 ILinkClient。""" self._http_client = httpx.AsyncClient( timeout=HTTP_TIMEOUT_SECONDS, limits=httpx.Limits( max_connections=self._max_connections, max_keepalive_connections=DEFAULT_MAX_KEEPALIVE_CONNECTIONS, ), ) if self._client is not None: self._client.attach_http_client(self._http_client) await self._logger.info("WeChat iLink plugin initialized") async def onStart(self) -> None: """标记插件就绪:允许 ILinkClient 接受新请求。""" self._started = True if self._client is not None: self._client.set_active(True) await self._logger.info("WeChat iLink plugin started") async def onStop(self) -> None: """停止插件:拒绝新请求,等待在途请求完成(默认 30s 超时)。 先 ``set_active(False)`` 阻止新请求进入 ``_execute_http``,再 ``drain(30)`` 等待在途请求完成。超时后记录告警但仍继续关闭流程。 """ await self._logger.info( "WeChat iLink plugin stopping, waiting for in-flight requests", ) self._started = False if self._client is not None: self._client.set_active(False) drained = await self._client.drain(timeout=DRAIN_TIMEOUT_SECONDS) if not drained: await self._logger.warn( "WeChat iLink plugin drain timed out, in-flight requests may be interrupted", timeout=DRAIN_TIMEOUT_SECONDS, ) async def onPause(self) -> None: """暂停插件:停止接收新请求。""" self._started = False if self._client is not None: self._client.set_active(False) await self._logger.info("WeChat iLink plugin paused") async def onResume(self) -> None: """恢复插件:重新接收请求。""" self._started = True if self._client is not None: self._client.set_active(True) await self._logger.info("WeChat iLink plugin resumed") async def onUnload(self) -> None: """释放所有资源:drain 在途请求后关闭 httpx 连接池并清空缓存。 长轮询连接由 ``StreamWorker`` 在宿主关停时首先停止, 本方法先 drain 在途请求(默认 30s 超时),再解除 client 引用并 关闭连接池,最后清空 CachePort 中 ``wechat_ilink:*`` 缓存避免 孤儿键残留(FR-32 资源释放约束)。 """ # 1. 先 drain 在途请求,避免 aclose 打断正在进行的请求 if self._client is not None: self._client.set_active(False) drained = await self._client.drain(timeout=DRAIN_TIMEOUT_SECONDS) if not drained: await self._logger.warn( "WeChat iLink plugin unload drain timed out, in-flight requests may be interrupted", timeout=DRAIN_TIMEOUT_SECONDS, ) self._client.detach_http_client() # 2. 关闭 httpx 连接池(异常告警不抛,避免阻断后续缓存清理) if self._http_client is not None: try: await self._http_client.aclose() except Exception as exc: await self._logger.warn( "WeChat iLink httpx client close failed during unload", error=str(exc), ) self._http_client = None # 3. 清空 CachePort 中 wechat_ilink:* 缓存,避免孤儿键残留 try: await self._cache.invalidate(pattern=_CACHE_INVALIDATE_PATTERN) except Exception as exc: await self._logger.warn( "WeChat iLink cache invalidate failed during unload", error=str(exc), ) self._started = False await self._logger.info("WeChat iLink plugin unloaded") async def onReconfigure( self, *, old_config: dict[str, Any] | None = None, new_config: dict[str, Any], ) -> None: """配置热更新:校验不可热更新字段。 - ``_restart_required_keys``(从 config_schema 派生,``hot_reloadable=False`` 的字段)变更抛 ``ConfigRestartRequiredError``(FR-37)。 - ``old_config`` 为 ``None``(首次调用或无基线)时仅记录日志,不做对比。 - 校验失败由宿主回滚,本方法无副作用(不缓存基线,基线由宿主维护)。 """ if old_config is None: await self._logger.info( "WeChat iLink plugin initial config baseline set", ) return for key in self._restart_required_keys: new_value = new_config.get(key) old_value = old_config.get(key) if new_value != old_value: raise ConfigRestartRequiredError(key=key) from None await self._logger.info("WeChat iLink plugin reconfigured") async def onFail(self, error: str) -> None: """失败清理:不抛异常,不阻断降级流程(FR-36)。 logger 不可用时回退到 ``sys.stderr.write``,确保失败信息不丢失、 不被静默吞掉,同时不向上抛出异常以保证降级流程继续。 """ self._started = False try: await self._logger.error("WeChat iLink plugin failed", error=error) except Exception as exc: sys.stderr.write(f"WeChat iLink plugin failed (logger unavailable): {error}\nlogger_error={exc!r}\n") __all__ = ["WeChatILinkLifecycleHandler"]