Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/plugins/core/runtime/asgi.py
T
insistence 4bd83bc7cd feat: 支持 v2 插件、独立前端和签名制品发布 (#130)
* feat: 支持 v2 插件、原生扩展与签名发布

* fix: 修复测试

* feat: 增强宿主 SDK、运行观测与制品维护能力

* feat: 增强文件传输、配置观测与本地开发能力

* feat: 支持 SSE 实时事件流并完善开发脚手架

* feat: 完善长连接鉴权复核与关闭回收

* feat: 增强生命周期与任务关闭可靠性

* feat: 收紧健康检查契约并补齐前端与真实网络验收

* feat: 增加SDK溯源与离线更新工具并验证跨版本兼容

* feat: 增加持久化业务示例与受控 Python 交付

* feat: 增加统一运行诊断与多worker状态观测

* feat: 增加固定目标发布等待与自动验收

* fix: 修复多环境插件部署前缀与代理配置不一致

* fix: 修复测试
2026-10-09 23:49:45 +08:00

290 lines
10 KiB
Python

import asyncio
import math
from collections.abc import Awaitable, Callable
from contextlib import suppress
from typing import Any
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from plugins.core.runtime.connections import (
CANCEL_TIMEOUT_SECONDS,
RECHECK_INTERVAL_SECONDS,
RECHECK_TIMEOUT_SECONDS,
PluginConnectionManager,
)
from plugins.core.runtime.task_cleanup import cancel_plugin_tasks
from plugins.core.sdk.context import PluginRequestContext
from utils.log_util import logger
class PluginLifespanManager:
"""
通过 ASGI lifespan 协议驱动一个 worker 内的插件实例。
"""
def __init__(
self,
app: ASGIApp,
*,
timeout: float = 30.0,
managed: bool = True,
cancel_timeout: float = CANCEL_TIMEOUT_SECONDS,
) -> None:
"""
初始化当前 worker 的插件生命周期管理器。
:param app: 插件 ASGI 应用
:param timeout: 生命周期事件确认的超时时间,单位为秒
:param managed: 是否由宿主驱动 ASGI 生命周期协议
:param cancel_timeout: 生命周期任务取消的等待上限秒数
:return: None
"""
if any(not math.isfinite(value) or value <= 0 for value in (timeout, cancel_timeout)):
raise ValueError('生命周期等待时间必须大于零')
self.app = app
self.timeout = timeout
self.cancel_timeout = cancel_timeout
self.managed = managed
self.state: dict[str, Any] = {}
self.started = False
self._stopping = False
self._operation_lock = asyncio.Lock()
self._task: asyncio.Task[None] | None = None
self._receive: asyncio.Queue[Message] = asyncio.Queue()
self._send: asyncio.Queue[Message] = asyncio.Queue()
@property
def has_pending_task(self) -> bool:
"""
判断上一次生命周期是否仍占用资源,防止重试创建重叠实例。
:return: 是否存在尚未退出的生命周期任务
"""
return self._task is not None and not self._task.done()
@property
def ready(self) -> bool:
"""
lifespan 意外退出后不再接受业务请求。
:return: 插件是否已启动且生命周期任务仍然存活
"""
return self.started and not self._stopping and (not self.managed or self.has_pending_task)
async def startup(self) -> None:
"""
只有确认 startup.complete 后才允许接收业务请求。
:return: None
"""
async with self._operation_lock:
self._stopping = False
await self._startup()
async def _startup(self) -> None:
"""
串行启动新一轮协议,旧任务退出后才允许重试。
:return: None
"""
if self.started:
return
if self.has_pending_task:
raise RuntimeError('上一次插件生命周期尚未退出,不能重新启动')
self.state = {}
self._receive = asyncio.Queue()
self._send = asyncio.Queue()
if not self.managed:
self.started = True
return
scope = {'type': 'lifespan', 'asgi': {'version': '3.0', 'spec_version': '2.0'}, 'state': self.state}
self._task = asyncio.create_task(self._invoke(scope))
try:
await self._exchange('startup')
self.started = True
except BaseException:
await self._cancel_task()
raise
async def shutdown(self) -> None:
"""
即使协议失败或超时,也回收本 worker 创建的 lifespan task。
:return: None
"""
self._stopping = True
async with self._operation_lock:
await self._shutdown()
async def _shutdown(self) -> None:
"""
串行发送关闭消息,避免并发调用消费同一个协议确认。
:return: None
"""
was_started = self.started
self.started = False
try:
if was_started and self.managed:
await self._exchange('shutdown')
done, _ = await asyncio.wait({self._task}, timeout=self.timeout)
if not done:
raise TimeoutError('ASGI lifespan.shutdown 确认后未退出')
self._check_task_result()
finally:
await self._cancel_task()
async def _invoke(self, scope: Scope) -> None:
"""
在独立协程中等待 ASGI 应用返回的任意 awaitable。
:param scope: 当前启动轮次的独立生命周期作用域
:return: None
"""
await self.app(scope, self._receive.get, self._send.put)
async def _exchange(self, event: str) -> None:
"""
发送生命周期事件并等待子应用确认。
:param event: 生命周期事件名称,支持 startup 和 shutdown
:return: None
"""
await self._receive.put({'type': f'lifespan.{event}'})
response = asyncio.create_task(self._send.get())
try:
done, _ = await asyncio.wait(
(response, self._task), timeout=self.timeout, return_when=asyncio.FIRST_COMPLETED
)
if response not in done:
if self._task in done:
self._check_task_result()
raise RuntimeError(f'ASGI 应用在 lifespan.{event} 确认前退出')
raise TimeoutError(f'ASGI lifespan.{event} 超时')
message = response.result()
if message.get('type') != f'lifespan.{event}.complete':
raise RuntimeError(f'ASGI lifespan.{event} 失败:{message.get("message", message.get("type"))}')
if event == 'startup' and self._task.done():
self._check_task_result()
raise RuntimeError('ASGI lifespan 在 startup 后提前退出')
finally:
response.cancel()
with suppress(asyncio.CancelledError):
await response
def _check_task_result(self) -> None:
"""
将子应用自行取消作为协议失败,保留宿主调用者自身的取消语义。
:return: None
"""
if self._task.cancelled():
raise RuntimeError('ASGI 生命周期任务自行取消')
self._task.result()
async def _cancel_task(self) -> None:
"""
取消并回收当前 worker 的生命周期任务。
:return: None
"""
if self._task is None:
return
if await cancel_plugin_tasks({self._task}, timeout=self.cancel_timeout):
logger.warning('插件生命周期未响应取消;保留任务并拒绝重启,等待任务自行退出')
else:
self._task = None
class PluginGatewayASGI:
"""
HTTP、静态资源和 WebSocket 共用的宿主门禁。
接受宿主 Bearer 或限定 bundle 的插件会话,由宿主授权器校验。
静态资源与业务接口都必须经过同一身份门禁。
"""
def __init__(
self,
app: ASGIApp,
lifespan: PluginLifespanManager,
authorize: Callable[[Scope], Awaitable[PluginRequestContext]],
*,
recheck_interval: float = RECHECK_INTERVAL_SECONDS,
recheck_timeout: float = RECHECK_TIMEOUT_SECONDS,
cancel_timeout: float = CANCEL_TIMEOUT_SECONDS,
) -> None:
"""
初始化插件请求门禁。
:param app: 受保护的插件 ASGI 应用
:param lifespan: 当前插件的生命周期管理器
:param authorize: 根据 ASGI 请求作用域构建宿主身份上下文的异步授权器
:param recheck_interval: 已建立长连接的身份复核间隔秒数
:param recheck_timeout: 每次长连接身份复核的超时秒数
:param cancel_timeout: 长连接任务取消的等待秒数
:return: None
"""
self.app = app
self.lifespan = lifespan
self.authorize = authorize
self.connections = PluginConnectionManager(
authorize,
lambda: lifespan.ready,
recheck_interval=recheck_interval,
recheck_timeout=recheck_timeout,
cancel_timeout=cancel_timeout,
)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"""
校验插件就绪状态及访问权限,并向子应用注入请求上下文。
:param scope: ASGI 请求作用域
:param receive: ASGI 消息接收函数
:param send: ASGI 消息发送函数
:return: None
"""
if scope['type'] not in {'http', 'websocket'}:
raise RuntimeError('插件 lifespan 必须由宿主管理器驱动')
if not self.lifespan.ready or self.connections.closing:
await self._reject(scope, receive, send, 503, '插件尚未就绪')
return
try:
context = await self.authorize(scope)
except PermissionError:
await self._reject(scope, receive, send, 403, '插件未启用或没有访问权限')
return
except LookupError:
await self._reject(scope, receive, send, 401, '请先登录')
return
child_scope = dict(scope)
child_scope['state'] = {**scope.get('state', {}), **self.lifespan.state, 'plugin_context': context}
await self.connections.run(self.app, scope, child_scope, context, receive, send)
async def drain(self) -> None:
"""
在释放插件资源前停止当前 worker 的 SSE 与 WebSocket。
:return: None
"""
await self.connections.drain()
@staticmethod
async def _reject(scope: Scope, receive: Receive, send: Send, status: int, message: str) -> None:
"""
按请求协议返回拒绝响应或关闭 WebSocket 连接。
:param scope: ASGI 请求作用域
:param receive: ASGI 消息接收函数
:param send: ASGI 消息发送函数
:param status: HTTP 拒绝响应状态码
:param message: 拒绝原因
:return: None
"""
if scope['type'] == 'websocket':
await send({'type': 'websocket.close', 'code': 1008, 'reason': message})
else:
await JSONResponse({'code': status, 'msg': message}, status_code=status)(scope, receive, send)