feat(plugin-runtime): add plugin isolation IPC infrastructure
- Protocol layer: Envelope model with Pydantic schema, MsgPack/JSON codecs, unified error codes - Transport layer: cross-platform IPC abstraction with 4-byte length-prefixed framing (UDS + TCP fallback) - Host: RPC server, policy engine, circuit breaker, capability service, supervisor with hot-reload - Runner: RPC client, plugin loader, process entry point - Tests: 16 passing tests covering protocol, transport, host, and E2E handshake
This commit is contained in:
1
src/plugin_runtime/transport/__init__.py
Normal file
1
src/plugin_runtime/transport/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Transport 层 - 跨平台本地 IPC 传输抽象
|
||||
116
src/plugin_runtime/transport/base.py
Normal file
116
src/plugin_runtime/transport/base.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""传输层抽象基类
|
||||
|
||||
定义 TransportServer 和 TransportClient 的统一接口。
|
||||
所有传输后端(UDS、Named Pipe、TCP 回退)必须实现此接口。
|
||||
业务层仅依赖此抽象,禁止直接使用具体传输实现的细节。
|
||||
|
||||
分帧协议:4-byte big-endian length prefix + payload
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import AsyncIterator, Callable, Awaitable
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
# 分帧常量
|
||||
FRAME_HEADER_SIZE = 4 # 4 字节长度前缀
|
||||
MAX_FRAME_SIZE = 16 * 1024 * 1024 # 16 MB 最大帧大小
|
||||
|
||||
|
||||
class ConnectionClosed(Exception):
|
||||
"""连接已关闭"""
|
||||
pass
|
||||
|
||||
|
||||
class Connection(ABC):
|
||||
"""单个连接的抽象
|
||||
|
||||
封装了底层 StreamReader/StreamWriter,提供分帧读写能力。
|
||||
"""
|
||||
|
||||
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
self._reader = reader
|
||||
self._writer = writer
|
||||
self._closed = False
|
||||
|
||||
async def send_frame(self, data: bytes) -> None:
|
||||
"""发送一帧数据(4-byte length prefix + payload)"""
|
||||
if self._closed:
|
||||
raise ConnectionClosed("连接已关闭")
|
||||
length = len(data)
|
||||
if length > MAX_FRAME_SIZE:
|
||||
raise ValueError(f"帧大小 {length} 超过最大限制 {MAX_FRAME_SIZE}")
|
||||
header = struct.pack(">I", length)
|
||||
self._writer.write(header + data)
|
||||
await self._writer.drain()
|
||||
|
||||
async def recv_frame(self) -> bytes:
|
||||
"""接收一帧数据"""
|
||||
if self._closed:
|
||||
raise ConnectionClosed("连接已关闭")
|
||||
# 读取 4 字节长度头
|
||||
header = await self._reader.readexactly(FRAME_HEADER_SIZE)
|
||||
(length,) = struct.unpack(">I", header)
|
||||
if length > MAX_FRAME_SIZE:
|
||||
raise ValueError(f"帧大小 {length} 超过最大限制 {MAX_FRAME_SIZE}")
|
||||
# 读取 payload
|
||||
payload = await self._reader.readexactly(length)
|
||||
return payload
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭连接"""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
self._writer.close()
|
||||
await self._writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
return self._closed
|
||||
|
||||
|
||||
# 连接回调类型:收到新连接时调用
|
||||
ConnectionHandler = Callable[[Connection], Awaitable[None]]
|
||||
|
||||
|
||||
class TransportServer(ABC):
|
||||
"""传输服务端抽象
|
||||
|
||||
Host 端使用,监听来自 Runner 的连接。
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def start(self, handler: ConnectionHandler) -> None:
|
||||
"""启动服务端,开始监听连接
|
||||
|
||||
Args:
|
||||
handler: 新连接到来时的回调函数
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def stop(self) -> None:
|
||||
"""停止服务端"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_address(self) -> str:
|
||||
"""获取监听地址(供 Runner 连接用)"""
|
||||
...
|
||||
|
||||
|
||||
class TransportClient(ABC):
|
||||
"""传输客户端抽象
|
||||
|
||||
Runner 端使用,主动连接 Host。
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self) -> Connection:
|
||||
"""建立到 Host 的连接"""
|
||||
...
|
||||
46
src/plugin_runtime/transport/factory.py
Normal file
46
src/plugin_runtime/transport/factory.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""传输层工厂
|
||||
|
||||
根据运行平台自动选择最优传输实现。
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from .base import TransportClient, TransportServer
|
||||
|
||||
|
||||
def create_transport_server(socket_path: str | None = None) -> TransportServer:
|
||||
"""创建传输服务端
|
||||
|
||||
Linux/macOS 使用 UDS,Windows 使用 TCP 回退。
|
||||
|
||||
Args:
|
||||
socket_path: UDS socket 路径(仅 Linux/macOS 有效)
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
from .uds import UDSTransportServer
|
||||
return UDSTransportServer(socket_path=socket_path)
|
||||
else:
|
||||
# Windows 回退到 TCP(后续可改为 Named Pipe)
|
||||
from .tcp import TCPTransportServer
|
||||
return TCPTransportServer()
|
||||
|
||||
|
||||
def create_transport_client(address: str) -> TransportClient:
|
||||
"""创建传输客户端
|
||||
|
||||
根据地址格式自动判断传输类型:
|
||||
- 包含 '/' 或 '.sock' -> UDS
|
||||
- 包含 ':' -> TCP
|
||||
|
||||
Args:
|
||||
address: Host 端监听地址
|
||||
"""
|
||||
if "/" in address or address.endswith(".sock"):
|
||||
from .uds import UDSTransportClient
|
||||
return UDSTransportClient(socket_path=address)
|
||||
elif ":" in address:
|
||||
from .tcp import TCPTransportClient
|
||||
host, port_str = address.rsplit(":", 1)
|
||||
return TCPTransportClient(host=host, port=int(port_str))
|
||||
else:
|
||||
raise ValueError(f"无法识别的传输地址格式: {address}")
|
||||
59
src/plugin_runtime/transport/tcp.py
Normal file
59
src/plugin_runtime/transport/tcp.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""TCP 传输实现(回退方案)
|
||||
|
||||
仅当 UDS / Named Pipe 不可用时启用。
|
||||
绑定到 127.0.0.1 避免远程访问,但仍需会话令牌做身份校验。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from .base import Connection, ConnectionHandler, TransportClient, TransportServer
|
||||
|
||||
|
||||
class TCPConnection(Connection):
|
||||
"""基于 TCP 的连接"""
|
||||
pass
|
||||
|
||||
|
||||
class TCPTransportServer(TransportServer):
|
||||
"""TCP 传输服务端(回退方案)"""
|
||||
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = 0):
|
||||
self._host = host
|
||||
self._port = port # 0 表示自动分配
|
||||
self._server: asyncio.AbstractServer | None = None
|
||||
self._actual_port: int = 0
|
||||
|
||||
async def start(self, handler: ConnectionHandler) -> None:
|
||||
async def _on_connect(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
conn = TCPConnection(reader, writer)
|
||||
try:
|
||||
await handler(conn)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
self._server = await asyncio.start_server(_on_connect, self._host, self._port)
|
||||
|
||||
# 获取实际分配的端口
|
||||
addr = self._server.sockets[0].getsockname()
|
||||
self._actual_port = addr[1]
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._server:
|
||||
self._server.close()
|
||||
await self._server.wait_closed()
|
||||
self._server = None
|
||||
|
||||
def get_address(self) -> str:
|
||||
return f"{self._host}:{self._actual_port}"
|
||||
|
||||
|
||||
class TCPTransportClient(TransportClient):
|
||||
"""TCP 传输客户端"""
|
||||
|
||||
def __init__(self, host: str, port: int):
|
||||
self._host = host
|
||||
self._port = port
|
||||
|
||||
async def connect(self) -> Connection:
|
||||
reader, writer = await asyncio.open_connection(self._host, self._port)
|
||||
return TCPConnection(reader, writer)
|
||||
71
src/plugin_runtime/transport/uds.py
Normal file
71
src/plugin_runtime/transport/uds.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Unix Domain Socket 传输实现
|
||||
|
||||
适用于 Linux / macOS 平台。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from .base import Connection, ConnectionHandler, TransportClient, TransportServer
|
||||
|
||||
|
||||
class UDSConnection(Connection):
|
||||
"""基于 UDS 的连接"""
|
||||
pass # 直接复用 Connection 基类的分帧读写
|
||||
|
||||
|
||||
class UDSTransportServer(TransportServer):
|
||||
"""UDS 传输服务端"""
|
||||
|
||||
def __init__(self, socket_path: str | None = None):
|
||||
if socket_path is None:
|
||||
# 默认放在临时目录
|
||||
socket_path = os.path.join(tempfile.gettempdir(), f"maibot-plugin-{os.getpid()}.sock")
|
||||
self._socket_path = socket_path
|
||||
self._server: asyncio.AbstractServer | None = None
|
||||
|
||||
async def start(self, handler: ConnectionHandler) -> None:
|
||||
# 清理残留 socket 文件
|
||||
if os.path.exists(self._socket_path):
|
||||
os.unlink(self._socket_path)
|
||||
|
||||
# 确保父目录存在
|
||||
Path(self._socket_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def _on_connect(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
conn = UDSConnection(reader, writer)
|
||||
try:
|
||||
await handler(conn)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
self._server = await asyncio.start_unix_server(_on_connect, path=self._socket_path)
|
||||
|
||||
# 设置文件权限为仅当前用户可访问
|
||||
os.chmod(self._socket_path, 0o600)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._server:
|
||||
self._server.close()
|
||||
await self._server.wait_closed()
|
||||
self._server = None
|
||||
# 清理 socket 文件
|
||||
if os.path.exists(self._socket_path):
|
||||
os.unlink(self._socket_path)
|
||||
|
||||
def get_address(self) -> str:
|
||||
return self._socket_path
|
||||
|
||||
|
||||
class UDSTransportClient(TransportClient):
|
||||
"""UDS 传输客户端"""
|
||||
|
||||
def __init__(self, socket_path: str):
|
||||
self._socket_path = socket_path
|
||||
|
||||
async def connect(self) -> Connection:
|
||||
reader, writer = await asyncio.open_unix_connection(self._socket_path)
|
||||
return UDSConnection(reader, writer)
|
||||
Reference in New Issue
Block a user