feat: 实现插件注册的分阶段接入与切换机制,优化 RPC 连接管理

This commit is contained in:
DrSmoothl
2026-03-13 15:21:40 +08:00
parent 5d30b3a908
commit 44a9e9ecd7
5 changed files with 393 additions and 87 deletions

View File

@@ -677,6 +677,28 @@ class TestComponentRegistry:
assert removed == 2
assert reg.get_stats()["total"] == 1
def test_reregister_same_plugin_replaces_component_set(self):
from src.plugin_runtime.host.component_registry import ComponentRegistry
reg = ComponentRegistry()
reg.register_plugin_components(
"p1",
[
{"name": "a1", "component_type": "action", "metadata": {}},
{"name": "a2", "component_type": "action", "metadata": {}},
],
)
reg.remove_components_by_plugin("p1")
reg.register_plugin_components(
"p1",
[
{"name": "a1", "component_type": "action", "metadata": {}},
],
)
assert reg.get_component("p1.a1") is not None
assert reg.get_component("p1.a2") is None
def test_event_handlers_sorted_by_weight(self):
from src.plugin_runtime.host.component_registry import ComponentRegistry
@@ -1257,7 +1279,7 @@ class TestRPCServer:
loop = asyncio.new_event_loop()
try:
future = loop.create_future()
server._pending_requests[1] = future
server._pending_requests[1] = (future, 2)
stale_response = Envelope(
request_id=1,
@@ -1274,23 +1296,53 @@ class TestRPCServer:
loop.close()
class TestRPCClient:
"""Runner RPCClient 后台任务生命周期测试"""
@pytest.mark.asyncio
async def test_background_tasks_retained_and_cancelled_on_disconnect(self):
from src.plugin_runtime.runner.rpc_client import RPCClient
client = RPCClient(host_address="dummy", session_token="token")
release = asyncio.Event()
async def pending_task():
await release.wait()
task = asyncio.create_task(pending_task())
client._track_background_task(task)
assert task in client._background_tasks
await asyncio.sleep(0)
assert task in client._background_tasks
await client.disconnect()
assert task.cancelled() is True
assert not client._background_tasks
class TestSupervisor:
"""Supervisor 生命周期边界测试"""
@staticmethod
def _build_register_payload(plugin_id: str = "plugin_a"):
def _build_register_payload(plugin_id: str = "plugin_a", component_names=None):
from src.plugin_runtime.protocol.envelope import ComponentDeclaration, RegisterComponentsPayload
component_names = component_names or ["handler"]
return RegisterComponentsPayload(
plugin_id=plugin_id,
plugin_version="1.0.0",
components=[
ComponentDeclaration(
name="handler",
name=name,
component_type="event_handler",
plugin_id=plugin_id,
metadata={"event_type": "on_message"},
)
for name in component_names
],
capabilities_required=["send.text"],
)
@@ -1331,8 +1383,11 @@ class TestSupervisor:
class FakeRPCServer:
def __init__(self):
self.runner_generation = 1
self.staged_generation = 0
self.is_connected = True
self.session_token = "fake-token"
self.committed = False
self.staging_started = False
def reset_session_token(self):
self.session_token = "new-fake-token"
@@ -1341,8 +1396,23 @@ class TestSupervisor:
def restore_session_token(self, token):
self.session_token = token
async def send_request(self, method, timeout_ms=5000, **kwargs):
assert self.runner_generation == 2
def begin_staged_takeover(self):
self.staging_started = True
self.staged_generation = 2
async def commit_staged_takeover(self):
self.runner_generation = self.staged_generation
self.staged_generation = 0
self.committed = True
async def rollback_staged_takeover(self):
self.staged_generation = 0
def has_generation(self, generation):
return generation in {self.runner_generation, self.staged_generation}
async def send_request(self, method, timeout_ms=5000, target_generation=None, **kwargs):
assert target_generation == 2
return SimpleNamespace(payload=HealthPayload(healthy=True).model_dump())
supervisor._rpc_server = FakeRPCServer()
@@ -1350,18 +1420,14 @@ class TestSupervisor:
async def fake_spawn_runner():
supervisor._runner_process = new_process
async def advance_generation():
await asyncio.sleep(0.01)
supervisor._rpc_server.runner_generation = 2
asyncio.create_task(advance_generation())
supervisor._staged_registered_plugins["plugin_a"] = self._build_register_payload("plugin_a")
monkeypatch.setattr(supervisor, "_spawn_runner", fake_spawn_runner)
await supervisor.reload_plugins("test")
assert supervisor._runner_process is new_process
assert supervisor._rpc_server.committed is True
assert old_process.terminated is True
@pytest.mark.asyncio
@@ -1380,6 +1446,69 @@ class TestSupervisor:
class FakeRPCServer:
def __init__(self):
self.runner_generation = 1
self.staged_generation = 0
self.is_connected = True
self.session_token = "fake-token"
self.rolled_back = False
def reset_session_token(self):
self.session_token = "new-fake-token"
return self.session_token
def restore_session_token(self, token):
self.session_token = token
def begin_staged_takeover(self):
self.staged_generation = 2
async def commit_staged_takeover(self):
self.runner_generation = self.staged_generation
self.staged_generation = 0
async def rollback_staged_takeover(self):
self.rolled_back = True
self.staged_generation = 0
def has_generation(self, generation):
return generation in {self.runner_generation, self.staged_generation}
async def send_request(self, method, timeout_ms=5000, target_generation=None, **kwargs):
raise RuntimeError("new runner unhealthy")
supervisor._rpc_server = FakeRPCServer()
async def fake_spawn_runner():
supervisor._runner_process = new_process
supervisor._staged_registered_plugins["plugin_a"] = self._build_register_payload("plugin_a")
monkeypatch.setattr(supervisor, "_spawn_runner", fake_spawn_runner)
await supervisor.reload_plugins("test")
assert supervisor._runner_process is old_process
assert supervisor._rpc_server.rolled_back is True
assert old_reg.plugin_id in supervisor._registered_plugins
assert supervisor.component_registry.get_component("plugin_a.handler") is not None
@pytest.mark.asyncio
async def test_reload_rebuilds_exact_component_set(self, monkeypatch):
from src.plugin_runtime.host.supervisor import PluginSupervisor
from src.plugin_runtime.protocol.envelope import HealthPayload
supervisor = PluginSupervisor(plugin_dirs=[])
old_process = self._make_process(1)
new_process = self._make_process(2)
old_reg = self._build_register_payload("plugin_a", component_names=["handler", "obsolete"])
new_reg = self._build_register_payload("plugin_a", component_names=["handler"])
supervisor._runner_process = old_process
supervisor._registered_plugins[old_reg.plugin_id] = old_reg
supervisor._rebuild_runtime_state()
class FakeRPCServer:
def __init__(self):
self.runner_generation = 1
self.staged_generation = 0
self.is_connected = True
self.session_token = "fake-token"
@@ -1390,22 +1519,34 @@ class TestSupervisor:
def restore_session_token(self, token):
self.session_token = token
async def send_request(self, method, timeout_ms=5000, **kwargs):
raise RuntimeError("new runner unhealthy")
def begin_staged_takeover(self):
self.staged_generation = 2
async def commit_staged_takeover(self):
self.runner_generation = self.staged_generation
self.staged_generation = 0
async def rollback_staged_takeover(self):
self.staged_generation = 0
def has_generation(self, generation):
return generation in {self.runner_generation, self.staged_generation}
async def send_request(self, method, timeout_ms=5000, target_generation=None, **kwargs):
return SimpleNamespace(payload=HealthPayload(healthy=True).model_dump())
supervisor._rpc_server = FakeRPCServer()
async def fake_spawn_runner():
supervisor._runner_process = new_process
supervisor._rpc_server.runner_generation = 2
supervisor._staged_registered_plugins[new_reg.plugin_id] = new_reg
monkeypatch.setattr(supervisor, "_spawn_runner", fake_spawn_runner)
await supervisor.reload_plugins("test")
assert supervisor._runner_process is old_process
assert old_reg.plugin_id in supervisor._registered_plugins
assert supervisor.component_registry.get_component("plugin_a.handler") is not None
assert supervisor.component_registry.get_component("plugin_a.obsolete") is None
@pytest.mark.asyncio
async def test_attach_stderr_drain_drains_stream(self):