"""微信 wechat_woc(WechatOnCloud)渠道插件生命周期钩子处理。 实现 ``LifecycleHookHandler`` Protocol 的 8 个钩子(onInit / onStart / onStop / onPause / onResume / onUnload / onReconfigure / onFail), 管理插件级资源(httpx 连接池)并响应宿主生命周期状态变迁。 设计要点: - LifecycleHandler 为单实例(非 per-account),可持有 mutable 状态用于 资源管理(与适配器的 INV-5 无 mutable 状态约束不同)。 - httpx 连接池在 ``onInit`` 创建并注入 ``WocBridgeClient``,``onUnload`` 解除引用后关闭;连接数上限对齐 manifest.json 中 ``resource_quota.max_connections=10``。 - 短轮询拉取由 ``StreamWorker`` 通过 ``PullerAdapter`` 统一管理, ``LifecycleHandler`` 不持有轮询句柄。 - ``onReconfigure`` 校验 ``bridge_url`` 格式(必须以 ``http://`` 或 ``https://`` 开头),非法时抛 ``ConfigRestartRequiredError`` 触发宿主 重启回滚(FR-37)。 - ``onUnload`` 在关闭连接池后清空 CachePort 中 ``wechat_woc:*`` 缓存, 避免插件卸载后残留孤儿缓存键(FR-32 资源释放约束)。 - ``onFail`` 不抛异常、不静默吞错;logger 失败时回退到 ``sys.stderr.write``, 不阻断 FR-36 优雅降级流程。 依赖方向:仅 import ``yuxi.channels.contract.*`` + 标准库 + httpx, 不污染框架层。 """ from __future__ import annotations import os 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 .woc_bridge_client import WocBridgeClient from ._constants import HTTP_TIMEOUT_SECONDS # httpx 连接池默认配置;最终值由 discover 阶段解析的 manifest.resource_quota # 注入,本默认值仅用于测试或未声明 resource_quota 的场景(F-03 单真相源)。 _HTTP_MAX_CONNECTIONS_DEFAULT = 10 _HTTP_MAX_KEEPALIVE_CONNECTIONS = 5 # 在途请求 drain 超时(秒),onStop/onUnload 等待在途请求完成的最长时间 _DRAIN_TIMEOUT_SECONDS = 30.0 # CachePort 失效模式(清空插件所有运行时缓存,避免孤儿键) _CACHE_INVALIDATE_PATTERN = "wechat_woc:*" class WeChatWocLifecycleHandler: """微信 wechat_woc 渠道插件生命周期钩子处理。 单实例,管理插件级资源生命周期。``_http_client`` 为共享连接池,通过 ``attach_http_client`` 注入 ``WocBridgeClient`` 供其复用(避免每请求 新建 TCP 连接);短轮询连接由 ``StreamWorker`` 管理,本处理器不持有 拉取句柄。 实现 8 个钩子(manifest.lifecycle 声明 init/start/stop/pause/resume/ unload/reconfigure/fail)。``onPause`` / ``onResume`` 通过 ``set_active`` 切换 ``WocBridgeClient`` 请求接收开关,语义为"暂停 接收新请求"而非"停止拉取"——拉取 Worker 由宿主 ``StreamWorker`` 在 PAUSED 状态下统一挂起,本钩子仅控制 HTTP 客户端可用性。 """ def __init__( self, config_port: ConfigPort, logger_port: LoggerPort, cache_port: CachePort, config_schema: tuple[ConfigField, ...], client: WocBridgeClient | None = None, max_connections: int = _HTTP_MAX_CONNECTIONS_DEFAULT, ) -> 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 保留用于未来扩展(如派生热更新键集合),当前 onReconfigure # 仅校验 bridge_url 格式,不依赖 schema 派生键清单。 self._config_schema: tuple[ConfigField, ...] = config_schema # ------------------------------------------------------------------ # LifecycleHookHandler Protocol 实现(8 个钩子) # ------------------------------------------------------------------ async def onInit(self) -> None: """初始化资源:创建 httpx 连接池并注入 WocBridgeClient。 超时 60s(由宿主控制),超时后标记插件失败。 @pre - 宿主已注入 ConfigPort / LoggerPort / CachePort - ``client``(若提供)尚未持有 httpx 连接池 @post - ``_http_client`` 已创建并注入 ``WocBridgeClient``(若提供) - 后续 HTTP 请求复用该连接池 @failure - 无(httpx.AsyncClient 构造失败由宿主捕获并触发 onFail) @consistency - 状态机驱动:钩子完成后状态机推进至 INITIALIZED """ timeout_seconds = _read_http_timeout() self._http_client = httpx.AsyncClient( timeout=timeout_seconds, limits=httpx.Limits( max_connections=self._max_connections, max_keepalive_connections=_HTTP_MAX_KEEPALIVE_CONNECTIONS, ), ) if self._client is not None: self._client.attach_http_client(self._http_client) await self._logger.info("WeChat woc plugin initialized") async def onStart(self) -> None: """标记插件就绪:允许 WocBridgeClient 接受新请求。 超时 60s(由宿主控制)。 @post - ``_started=True`` - ``WocBridgeClient.set_active(True)`` 已调用 @consistency - 状态机驱动:钩子完成后状态机推进至 STARTED """ self._started = True if self._client is not None: self._client.set_active(True) await self._logger.info("WeChat woc plugin started") async def onStop(self) -> None: """停止插件:拒绝新请求,等待在途请求完成(默认 30s 超时)。 先 ``set_active(False)`` 阻止新请求进入 ``_execute_http``,再 ``drain(30)`` 等待在途请求完成。超时后记录告警但仍继续关闭流程。 @post - ``_started=False`` - ``WocBridgeClient.set_active(False)`` 已调用 - 在途请求已 drain(超时则告警,不抛异常) @consistency - 状态机驱动:钩子完成后状态机推进至 STOPPED - 幂等:停止已停止的插件安全 """ await self._logger.info( "WeChat woc plugin stopping, waiting for in-flight requests", ) self._started = False if self._client is not None: self._client.set_active(False) await self._client.drain(timeout=_DRAIN_TIMEOUT_SECONDS) async def onPause(self) -> None: """暂停插件:停止接收新请求,但在途请求继续完成。 与 ``onStop`` 的区别:不 drain 在途请求,仅 ``set_active(False)`` 阻止新请求进入 ``_execute_http``,已接收的请求继续执行至完成。 适用于宿主临时挂起场景(如配置预校验、限流降级),恢复后调用 ``onResume`` 即可重新接收请求。 @post - ``_started=False`` - ``WocBridgeClient.set_active(False)`` 已调用 @consistency - 状态机驱动:钩子完成后状态机推进至 PAUSED - 幂等:暂停已暂停的插件安全 """ self._started = False if self._client is not None: self._client.set_active(False) await self._logger.info("WeChat woc plugin paused") async def onResume(self) -> None: """恢复插件:重新接收新请求。 仅在 PAUSED 状态下由宿主调用,恢复 ``WocBridgeClient`` 请求接收 开关。若插件未启动(``_http_client`` 为 None),仅标记 ``_started`` 等待 ``onStart`` 完成资源装配。 @pre - 插件处于 PAUSED 状态 @post - ``_started=True`` - ``WocBridgeClient.set_active(True)`` 已调用(若 client 存在) @consistency - 状态机驱动:钩子完成后状态机推进至 STARTED - 幂等:恢复已启动的插件安全 """ self._started = True if self._client is not None: self._client.set_active(True) await self._logger.info("WeChat woc plugin resumed") async def onUnload(self) -> None: """释放所有资源:drain 在途请求后关闭 httpx 连接池并清空缓存。 超时 30s(由宿主控制),必须释放所有资源,不得留下孤儿资源 (FR-32 资源释放约束)。 步骤: 1. ``set_active(False)`` + ``drain(30)`` 等待在途请求完成 2. ``detach_http_client`` 解除 client 引用(避免悬挂) 3. ``aclose()`` 关闭连接池(异常告警不抛) 4. ``cache_port.invalidate("wechat_woc:*")`` 清空插件运行时缓存 (异常告警不抛,遵循 CachePort 写故障降级约定) @post - ``_http_client=None``、``_started=False`` - CachePort 中 ``wechat_woc:*`` 模式缓存已失效 @consistency - 状态机驱动:钩子完成后状态机推进至 UNLOADED - 幂等:重复卸载仅清理已释放的资源 """ # 1. 先 drain 在途请求,避免 aclose 打断正在进行的请求 if self._client is not None: self._client.set_active(False) await self._client.drain(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 woc httpx client close failed during unload", error=str(exc), ) self._http_client = None # 3. 清空 CachePort 中 wechat_woc:* 缓存,避免孤儿键残留 try: await self._cache.invalidate(pattern=_CACHE_INVALIDATE_PATTERN) except Exception as exc: await self._logger.warn( "WeChat woc cache invalidate failed during unload", error=str(exc), ) self._started = False await self._logger.info("WeChat woc plugin unloaded") async def onReconfigure( self, *, old_config: dict[str, Any] | None = None, new_config: dict[str, Any], ) -> None: """配置热更新:校验 bridge_url 格式,非法抛 ConfigRestartRequiredError。 ``bridge_url`` 为 wechat_woc 唯一不可降级校验的配置项:必须以 ``http://`` 或 ``https://`` 开头,否则视为配置非法,抛 ``ConfigRestartRequiredError`` 触发宿主重启回滚(FR-37)。 校验通过后仅记录日志,不缓存基线(wechat_woc 的 bridge_url 实时 从 ConfigPort 读取,无需本地缓存)。``old_config`` 由宿主通过关键字 参数传入,wechat_woc 仅做格式校验、无差异化校验需求,故不消费。 @pre - ``new_config`` 为新配置字典,含 ``bridge_url`` 字段 @post - 校验通过:日志记录,配置即时生效(bridge_url 实时读取) - 校验失败:抛 ``ConfigRestartRequiredError``,宿主回滚 @failure - ``ConfigRestartRequiredError``:``bridge_url`` 格式非法 @consistency - 状态机驱动:配置变更即时生效且支持回滚 - 幂等:相同配置重复应用产生等价结果 """ bridge_url = new_config.get("bridge_url") if not isinstance(bridge_url, str) or not bridge_url: raise ConfigRestartRequiredError(key="bridge_url") from None if not (bridge_url.startswith("http://") or bridge_url.startswith("https://")): raise ConfigRestartRequiredError(key="bridge_url") from None await self._logger.info("WeChat woc plugin reconfigured") async def onFail(self, error: str) -> None: """失败清理:不抛异常,不阻断降级流程(FR-36)。 插件初始化/启动/运行时失败时由宿主调用,标记 ``_started=False`` 并记录错误日志。logger 不可用时回退到 ``sys.stderr.write``,确保 失败信息不丢失、不被静默吞掉,同时不向上抛出异常以保证降级流程 继续。 @post - ``_started=False`` - 错误已记录(logger 或 stderr 兜底) - 不抛出任何异常 @consistency - 状态机驱动:钩子完成后状态机推进至 FAILED 并触发降级 - 幂等:重复通知失败仅记录状态,无累积副作用 """ self._started = False try: await self._logger.error("WeChat woc plugin failed", error=error) except Exception as exc: sys.stderr.write(f"WeChat woc plugin failed (logger unavailable): {error}\nlogger_error={exc!r}\n") def _read_http_timeout() -> float: """读取 HTTP 客户端超时(秒)。 优先读取环境变量 ``WOC_HTTP_TIMEOUT_MS``(与 manifest.json env_vars 声明 对齐),解析为秒;未设置或解析失败时回退到 ``HTTP_TIMEOUT_SECONDS``。 Returns: HTTP 超时秒数(>=1.0)。 """ env_value = os.environ.get("WOC_HTTP_TIMEOUT_MS") if env_value: try: ms = int(env_value) if ms >= 1000: return ms / 1000.0 except (ValueError, TypeError): pass return HTTP_TIMEOUT_SECONDS __all__ = ["WeChatWocLifecycleHandler"]