refactor(runtime): lazily activate host modules (#6331)

This commit is contained in:
InfinityPacer
2026-08-16 16:07:38 +08:00
committed by GitHub
parent 98276a68a8
commit 24671f8f18
67 changed files with 7364 additions and 253 deletions
+195
View File
@@ -0,0 +1,195 @@
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from app.runtime.capabilities.errors import CapabilityManifestError
from app.runtime.capabilities.model import (
ActivationPolicy,
SelectorSchema,
)
from app.runtime.capabilities.registry import CapabilityRegistry
_BASE_MANIFEST = """
schema_version = 1
id = "sample.capability"
kind = "sample"
entrypoint = "sample_implementation:SampleCapability"
depends_on = []
[metadata]
name = "Sample capability"
priority = 10
[activation]
policy = "when_configured"
watch = ["sample.config"]
[activation.selector]
kind = "configured"
key = "sample.config"
enabled = true
"""
def _write_manifest(root: Path, content: str = _BASE_MANIFEST, name: str = "sample") -> Path:
manifest_dir = root / name
manifest_dir.mkdir(parents=True)
manifest_path = manifest_dir / "capability.toml"
manifest_path.write_text(content.strip() + "\n", encoding="utf-8")
return manifest_path
def _discover(root: Path) -> CapabilityRegistry:
return CapabilityRegistry.discover(
roots=[root],
kinds={"sample"},
selector_schemas={
"configured": SelectorSchema(
required_fields=frozenset({"key", "enabled"}),
)
},
)
def test_discovery_reads_toml_without_importing_entrypoint(tmp_path: Path) -> None:
"""能力发现只能读取声明,不能执行 entrypoint 对应的 Python 模块。"""
_write_manifest(tmp_path)
(tmp_path / "sample_implementation.py").write_text(
"raise AssertionError('entrypoint must not be imported during discovery')\n",
encoding="utf-8",
)
sys.modules.pop("sample_implementation", None)
registry = _discover(tmp_path)
spec = registry.get_spec("sample.capability")
assert spec is not None
assert spec.activation is ActivationPolicy.WHEN_CONFIGURED
assert spec.selector is not None
assert spec.selector.kind == "configured"
assert spec.selector.config == {"key": "sample.config", "enabled": True}
assert spec.watch == ("sample.config",)
assert spec.depends_on == ()
assert "sample_implementation" not in sys.modules
def test_discovered_specs_are_recursively_immutable(tmp_path: Path) -> None:
"""Registry 暴露的声明及嵌套 metadata/selector 都不能被调用方改写。"""
_write_manifest(tmp_path)
spec = _discover(tmp_path).require_spec("sample.capability")
with pytest.raises(TypeError):
spec.metadata["name"] = "changed"
with pytest.raises(TypeError):
spec.selector.config["key"] = "changed" # type: ignore[union-attr]
with pytest.raises(AttributeError):
spec.watch.append("changed") # type: ignore[attr-defined]
@pytest.mark.parametrize(
("replacement", "match"),
[
("schema_version = 1", "缺少字段"),
(_BASE_MANIFEST.replace("schema_version = 1", "schema_version = 2"), "schema_version"),
(_BASE_MANIFEST.replace("schema_version = 1", "schema_version = 1.0"), "schema_version"),
(_BASE_MANIFEST.replace('id = "sample.capability"', 'id = "bad id"'), "id"),
(_BASE_MANIFEST.replace('kind = "sample"', 'kind = "unknown"'), "kind"),
(
_BASE_MANIFEST.replace(
'entrypoint = "sample_implementation:SampleCapability"',
'entrypoint = "sample_implementation.SampleCapability"',
),
"entrypoint",
),
(_BASE_MANIFEST.replace('kind = "configured"', 'kind = "unknown"'), "selector"),
(_BASE_MANIFEST.replace('enabled = true', 'extra = true'), "selector"),
(_BASE_MANIFEST.replace("depends_on = []", 'depends_on = ["other"]'), "depends_on"),
(_BASE_MANIFEST.replace("watch =", "unknown_field = true\nwatch ="), "activation"),
],
)
def test_registry_fails_closed_for_invalid_manifest(
tmp_path: Path,
replacement: str,
match: str,
) -> None:
"""未知或不完整声明必须阻止 Registry 构建,不能静默丢失能力。"""
_write_manifest(tmp_path, replacement)
with pytest.raises(CapabilityManifestError, match=match):
_discover(tmp_path)
def test_selector_presence_must_match_activation_policy(tmp_path: Path) -> None:
"""只有 when_configured 声明可以且必须携带配置 selector。"""
bootstrap = _BASE_MANIFEST.replace(
'policy = "when_configured"', 'policy = "bootstrap"'
)
_write_manifest(tmp_path, bootstrap)
with pytest.raises(CapabilityManifestError, match="selector"):
_discover(tmp_path)
def test_registry_rejects_duplicate_ids_across_roots(tmp_path: Path) -> None:
"""多个声明根出现相同 capability ID 时必须 fail closed。"""
first_root = tmp_path / "first"
second_root = tmp_path / "second"
_write_manifest(first_root)
_write_manifest(second_root)
with pytest.raises(CapabilityManifestError, match="重复"):
CapabilityRegistry.discover(
roots=[first_root, second_root],
kinds={"sample"},
selector_schemas={
"configured": SelectorSchema(
required_fields=frozenset({"key", "enabled"}),
)
},
)
def test_registry_rejects_root_without_manifest(tmp_path: Path) -> None:
"""注册了声明根却没有任何 manifest 时应直接失败。"""
with pytest.raises(CapabilityManifestError, match="capability.toml"):
_discover(tmp_path)
def test_current_host_module_manifests_follow_the_strict_nested_schema() -> None:
"""仓内 Host Module 声明必须全部通过同一套嵌套 schema。"""
modules_root = Path(__file__).parents[1] / "app" / "modules"
imported_before = set(sys.modules)
registry = CapabilityRegistry.discover(
roots=[modules_root],
kinds={"host_module"},
selector_schemas={
"system_config_item": SelectorSchema(
required_fields=frozenset({
"key",
"match_field",
"match_value",
"enabled_field",
}),
),
"setting_truthy": SelectorSchema(
required_fields=frozenset({"key"}),
),
},
)
specs = registry.list_specs()
declared_directories = {spec.source.parent for spec in specs}
module_directories = {
path
for path in modules_root.iterdir()
if path.is_dir() and (path / "__init__.py").is_file()
}
entrypoint_modules = {spec.entrypoint.split(":", maxsplit=1)[0] for spec in specs}
assert declared_directories == module_directories
assert all(spec.kind == "host_module" for spec in specs)
assert not ((set(sys.modules) - imported_before) & entrypoint_modules)
+834
View File
@@ -0,0 +1,834 @@
from __future__ import annotations
import asyncio
import sys
import threading
import types
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from unittest.mock import patch
import pytest
from app.runtime.capabilities.errors import (
CapabilityAdapterModeError,
CapabilityOperationError,
CapabilityRuntimeClosedError,
)
from app.runtime.capabilities.model import (
AdapterExecutionMode,
CapabilityLifecycleState,
CapabilityMaterializationState,
)
from app.runtime.capabilities.registry import CapabilityRegistry
from app.runtime.capabilities.runtime import CapabilityRuntime
_MANIFEST = """
schema_version = 1
id = "sample.capability"
kind = "sample"
entrypoint = "sample_implementation:SampleCapability"
depends_on = []
[metadata]
name = "Sample capability"
[activation]
policy = "on_first_use"
watch = []
"""
def _registry(tmp_path: Path) -> CapabilityRegistry:
manifest_dir = tmp_path / "sample"
manifest_dir.mkdir(parents=True)
(manifest_dir / "capability.toml").write_text(
_MANIFEST.strip() + "\n",
encoding="utf-8",
)
return CapabilityRegistry.discover(
roots=[tmp_path],
kinds={"sample"},
selector_schemas={},
)
@dataclass
class _Candidate:
generation: int
started: bool = False
stopped: bool = False
class _SyncAdapter:
execution_mode = AdapterExecutionMode.SYNC
def __init__(self) -> None:
self.materialize_calls = 0
self.create_calls = 0
self.start_calls = 0
self.stop_calls = 0
self.stop_instances = []
self.cleanup_calls = 0
self.fail_materialize = False
self.fail_start = False
self.fail_stop = False
self.start_entered: Optional[threading.Event] = None
self.start_release: Optional[threading.Event] = None
self.stop_entered: Optional[threading.Event] = None
self.stop_release: Optional[threading.Event] = None
def materialize(self, spec) -> object:
self.materialize_calls += 1
if self.fail_materialize:
raise RuntimeError("materialize failed")
return object()
def create(self, spec, implementation: object, generation: int, previous: Any = None) -> _Candidate:
self.create_calls += 1
return _Candidate(generation=generation)
def start(self, spec, candidate: _Candidate, generation: int) -> None:
self.start_calls += 1
if self.start_entered:
self.start_entered.set()
if self.start_release:
assert self.start_release.wait(timeout=5)
candidate.started = True
if self.fail_start:
raise RuntimeError("start failed")
def stop(self, spec, instance: _Candidate, generation: int) -> None:
self.stop_calls += 1
self.stop_instances.append(instance)
if self.stop_entered:
self.stop_entered.set()
if self.stop_release:
assert self.stop_release.wait(timeout=5)
instance.stopped = True
if self.fail_stop:
raise RuntimeError("stop failed")
def cleanup(self, spec, candidate: _Candidate, generation: int, error: BaseException) -> None:
self.cleanup_calls += 1
candidate.stopped = True
class _AsyncAdapter:
execution_mode = AdapterExecutionMode.ASYNC
def __init__(self) -> None:
self.materialize_calls = 0
self.create_calls = 0
self.start_calls = 0
self.stop_calls = 0
self.stop_instances = []
self.cleanup_calls = 0
self.fail_materialize = False
self.fail_start = False
self.fail_stop = False
self.start_entered = asyncio.Event()
self.start_release = asyncio.Event()
self.stop_entered: Optional[asyncio.Event] = None
self.stop_release: Optional[asyncio.Event] = None
async def materialize(self, spec) -> object:
self.materialize_calls += 1
await asyncio.sleep(0)
if self.fail_materialize:
raise RuntimeError("async materialize failed")
return object()
async def create(self, spec, implementation: object, generation: int, previous: Any = None) -> _Candidate:
self.create_calls += 1
await asyncio.sleep(0)
return _Candidate(generation=generation)
async def start(self, spec, candidate: _Candidate, generation: int) -> None:
self.start_calls += 1
self.start_entered.set()
await self.start_release.wait()
candidate.started = True
if self.fail_start:
raise RuntimeError("async start failed")
async def stop(self, spec, instance: _Candidate, generation: int) -> None:
self.stop_calls += 1
self.stop_instances.append(instance)
if self.stop_entered:
self.stop_entered.set()
if self.stop_release:
await self.stop_release.wait()
await asyncio.sleep(0)
instance.stopped = True
if self.fail_stop:
raise RuntimeError("async stop failed")
async def cleanup(self, spec, candidate: _Candidate, generation: int, error: BaseException) -> None:
self.cleanup_calls += 1
await asyncio.sleep(0)
candidate.stopped = True
def test_materialize_and_start_have_independent_state_axes(tmp_path: Path) -> None:
"""兼容查询只物化代码,资源必须等显式 activate 成功后才对外可见。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
implementation = runtime.materialize("sample.capability", reason="compat_lookup")
materialized = runtime.snapshot("sample.capability")
assert implementation is not None
assert materialized.materialization is CapabilityMaterializationState.RESOLVED
assert materialized.lifecycle is CapabilityLifecycleState.DISCOVERED
assert materialized.visible is False
assert adapter.start_calls == 0
instance = runtime.activate("sample.capability", reason="first_use")
running = runtime.snapshot("sample.capability")
assert runtime.get_running("sample.capability") is instance
assert running.lifecycle is CapabilityLifecycleState.RUNNING
assert running.visible is True
assert running.generation == 2
def test_materialize_failure_does_not_claim_resource_lifecycle_failure(tmp_path: Path) -> None:
"""仅解析代码失败时,资源轴尚未启动,必须保持 DISCOVERED。"""
adapter = _SyncAdapter()
adapter.fail_materialize = True
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
with pytest.raises(CapabilityOperationError, match="materialize failed"):
runtime.materialize("sample.capability", reason="compat_lookup")
snapshot = runtime.snapshot("sample.capability")
assert snapshot.materialization is CapabilityMaterializationState.FAILED
assert snapshot.lifecycle is CapabilityLifecycleState.DISCOVERED
assert snapshot.visible is False
def test_state_read_calibrates_consumer_import_without_importing_new_module(tmp_path: Path) -> None:
"""显式旧导入存在时应复用 sys.modules 中的 canonical symbol。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
module = types.ModuleType("sample_implementation")
canonical = type("SampleCapability", (), {})
module.SampleCapability = canonical
with patch.dict(sys.modules, {"sample_implementation": module}):
snapshot = runtime.snapshot("sample.capability")
implementation = runtime.materialize(
"sample.capability",
reason="compat_lookup",
)
assert snapshot.materialization is CapabilityMaterializationState.RESOLVED
assert snapshot.lifecycle is CapabilityLifecycleState.DISCOVERED
assert snapshot.generation == 0
assert implementation is canonical
assert adapter.materialize_calls == 0
def test_state_read_does_not_invoke_module_level_lazy_export(tmp_path: Path) -> None:
"""sys.modules 校准只能读模块字典,不能触发模块级 __getattr__。"""
runtime = CapabilityRuntime(
_registry(tmp_path),
adapters={"sample": _SyncAdapter()},
)
module = types.ModuleType("sample_implementation")
lazy_reads = []
def resolve(name: str) -> object:
lazy_reads.append(name)
raise AssertionError("state read must not resolve lazy exports")
module.__getattr__ = resolve
with patch.dict(sys.modules, {"sample_implementation": module}):
snapshot = runtime.snapshot("sample.capability")
assert snapshot.materialization is CapabilityMaterializationState.UNRESOLVED
assert lazy_reads == []
def test_sync_activate_is_single_flight_and_publishes_only_after_start(tmp_path: Path) -> None:
"""并发首启只能创建一个候选实例,start 返回前普通查询不可见。"""
adapter = _SyncAdapter()
adapter.start_entered = threading.Event()
adapter.start_release = threading.Event()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
results = []
errors = []
def activate() -> None:
try:
results.append(runtime.activate("sample.capability", reason="concurrent"))
except BaseException as error: # pragma: no cover - diagnostic collection
errors.append(error)
first = threading.Thread(target=activate)
second = threading.Thread(target=activate)
first.start()
assert adapter.start_entered.wait(timeout=5)
second.start()
assert runtime.get_running("sample.capability") is None
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STARTING
adapter.start_release.set()
first.join(timeout=5)
second.join(timeout=5)
assert not errors
assert len(results) == 2
assert results[0] is results[1]
assert adapter.materialize_calls == 1
assert adapter.create_calls == 1
assert adapter.start_calls == 1
assert runtime.snapshot("sample.capability").generation == 1
def test_failed_start_cleans_candidate_and_requires_explicit_retry(tmp_path: Path) -> None:
"""半初始化候选必须清理;FAILED 不得被普通 activate 隐式重试。"""
adapter = _SyncAdapter()
adapter.fail_start = True
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
with pytest.raises(CapabilityOperationError, match="start failed"):
runtime.activate("sample.capability", reason="first_attempt")
failed = runtime.snapshot("sample.capability")
assert failed.materialization is CapabilityMaterializationState.RESOLVED
assert failed.lifecycle is CapabilityLifecycleState.FAILED
assert failed.visible is False
assert adapter.cleanup_calls == 1
with pytest.raises(CapabilityOperationError, match="显式 retry"):
runtime.activate("sample.capability", reason="implicit_retry")
assert adapter.start_calls == 1
adapter.fail_start = False
instance = runtime.activate("sample.capability", reason="explicit_retry", retry=True)
assert instance.started is True
assert runtime.snapshot("sample.capability").generation == 2
assert adapter.start_calls == 2
def test_adapter_must_return_candidate_before_start(tmp_path: Path) -> None:
"""create 没有候选对象时不得进入 start 或伪造 RUNNING 可见性。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
with patch.object(adapter, "create", return_value=None), pytest.raises(
CapabilityOperationError,
match="candidate",
):
runtime.activate("sample.capability", reason="invalid_candidate")
assert adapter.start_calls == 0
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.FAILED
assert runtime.get_running("sample.capability") is None
def test_stop_withdraws_visibility_before_adapter_callback(tmp_path: Path) -> None:
"""释放外部资源可能阻塞,但运行实例必须在 stop 回调前撤销发布。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
instance = runtime.activate("sample.capability", reason="start")
adapter.stop_entered = threading.Event()
adapter.stop_release = threading.Event()
stopper = threading.Thread(
target=lambda: runtime.stop("sample.capability", reason="configuration_removed")
)
stopper.start()
assert adapter.stop_entered.wait(timeout=5)
assert runtime.get_running("sample.capability") is None
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPING
adapter.stop_release.set()
stopper.join(timeout=5)
assert instance.stopped is True
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPED
def test_stop_failure_retains_ownership_until_same_instance_stops(tmp_path: Path) -> None:
"""stop 失败后的隐藏资源必须保留所有权,禁止用 retry 绕过清理。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
instance = runtime.activate("sample.capability", reason="start")
adapter.fail_stop = True
with pytest.raises(CapabilityOperationError, match="stop failed"):
runtime.stop("sample.capability", reason="configuration_removed")
assert runtime.get_running("sample.capability") is None
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.FAILED
with pytest.raises(CapabilityOperationError, match="stop failed"):
runtime.activate("sample.capability", reason="unsafe_retry", retry=True)
assert adapter.create_calls == 1
adapter.fail_stop = False
runtime.stop("sample.capability", reason="stop_retry")
assert adapter.stop_instances == [instance, instance, instance]
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPED
replacement = runtime.activate("sample.capability", reason="after_release")
assert replacement is not instance
assert adapter.create_calls == 2
def test_reload_withdraws_old_instance_and_publishes_one_new_generation(tmp_path: Path) -> None:
"""同步 reload 在 stop/start 回调期间不暴露旧实例或半初始化候选。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
old_instance = runtime.activate("sample.capability", reason="initial")
adapter.stop_entered = threading.Event()
adapter.stop_release = threading.Event()
results = []
reloader = threading.Thread(
target=lambda: results.append(runtime.reload("sample.capability", reason="config_changed"))
)
reloader.start()
assert adapter.stop_entered.wait(timeout=5)
assert runtime.get_running("sample.capability") is None
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.RELOADING
adapter.stop_release.set()
reloader.join(timeout=5)
assert len(results) == 1
assert results[0] is not old_instance
assert runtime.get_running("sample.capability") is results[0]
assert runtime.snapshot("sample.capability").generation == 2
def test_failed_reload_cleans_candidate_and_keeps_instance_invisible(tmp_path: Path) -> None:
"""reload 新 generation 启动失败时不得恢复旧实例或发布候选。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
runtime.activate("sample.capability", reason="initial")
adapter.fail_start = True
with pytest.raises(CapabilityOperationError, match="start failed"):
runtime.reload("sample.capability", reason="config_changed")
snapshot = runtime.snapshot("sample.capability")
assert snapshot.lifecycle is CapabilityLifecycleState.FAILED
assert snapshot.visible is False
assert adapter.cleanup_calls == 1
def test_reload_stop_failure_does_not_create_or_reuse_live_previous(tmp_path: Path) -> None:
"""reload 未释放旧资源时必须失败关闭,不能创建或复用同一活对象。"""
adapter = _SyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
old_instance = runtime.activate("sample.capability", reason="initial")
adapter.fail_stop = True
with pytest.raises(CapabilityOperationError, match="stop failed"):
runtime.reload("sample.capability", reason="config_changed")
assert runtime.get_running("sample.capability") is None
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.FAILED
assert adapter.stop_instances == [old_instance]
assert adapter.create_calls == 1
assert adapter.start_calls == 1
with pytest.raises(CapabilityOperationError, match="重试 stop"):
runtime.activate("sample.capability", reason="implicit_retry")
adapter.fail_stop = False
adapter.stop_entered = threading.Event()
adapter.stop_release = threading.Event()
recovered = []
recovery = threading.Thread(
target=lambda: recovered.append(
runtime.activate(
"sample.capability",
reason="recover_after_reload",
retry=True,
)
)
)
recovery.start()
assert adapter.stop_entered.wait(timeout=5)
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPING
assert runtime.get_running("sample.capability") is None
assert adapter.create_calls == 1
adapter.stop_release.set()
recovery.join(timeout=5)
assert len(recovered) == 1
replacement = recovered[0]
assert adapter.stop_instances == [old_instance, old_instance]
assert replacement is not old_instance
assert adapter.create_calls == 2
def test_shutdown_prevents_inflight_start_from_resurrecting_instance(tmp_path: Path) -> None:
"""shutdown 与首启竞争时,候选只能清理,不能在关闭开始后重新发布。"""
adapter = _SyncAdapter()
adapter.start_entered = threading.Event()
adapter.start_release = threading.Event()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
activate_errors = []
def activate() -> None:
try:
runtime.activate("sample.capability", reason="racing_start")
except BaseException as error:
activate_errors.append(error)
starter = threading.Thread(target=activate)
starter.start()
assert adapter.start_entered.wait(timeout=5)
closer = threading.Thread(target=lambda: runtime.shutdown(reason="application_shutdown"))
closer.start()
adapter.start_release.set()
starter.join(timeout=5)
closer.join(timeout=5)
assert len(activate_errors) == 1
assert isinstance(activate_errors[0], CapabilityRuntimeClosedError)
assert adapter.cleanup_calls == 1
assert runtime.get_running("sample.capability") is None
assert runtime.is_shutdown is True
with pytest.raises(CapabilityRuntimeClosedError):
runtime.activate("sample.capability", reason="late_start")
def test_shutdown_cannot_return_between_open_check_and_sync_claim(tmp_path: Path) -> None:
"""open check 与 inflight claim 必须共享 barrier,关闭扫描不能漏过首启。"""
adapter = _SyncAdapter()
adapter.start_entered = threading.Event()
adapter.start_release = threading.Event()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
check_entered = threading.Event()
check_release = threading.Event()
shutdown_returned = threading.Event()
activate_errors = []
original_ensure_open = runtime._ensure_open
first_check = True
check_lock = threading.Lock()
def gated_ensure_open() -> None:
nonlocal first_check
original_ensure_open()
with check_lock:
should_wait = first_check
first_check = False
if should_wait:
check_entered.set()
assert check_release.wait(timeout=5)
def activate() -> None:
try:
runtime.activate("sample.capability", reason="preclaim_race")
except BaseException as error:
activate_errors.append(error)
def shutdown() -> None:
runtime.shutdown(reason="application_shutdown")
shutdown_returned.set()
with patch.object(runtime, "_ensure_open", side_effect=gated_ensure_open):
starter = threading.Thread(target=activate)
starter.start()
assert check_entered.wait(timeout=5)
closer = threading.Thread(target=shutdown)
closer.start()
assert not shutdown_returned.wait(timeout=0.1)
check_release.set()
assert adapter.start_entered.wait(timeout=5)
assert not shutdown_returned.is_set()
adapter.start_release.set()
starter.join(timeout=5)
closer.join(timeout=5)
assert shutdown_returned.is_set()
assert len(activate_errors) <= 1
assert not activate_errors or isinstance(
activate_errors[0],
CapabilityRuntimeClosedError,
)
assert runtime.get_running("sample.capability") is None
@pytest.mark.asyncio
async def test_shutdown_cannot_return_between_open_check_and_async_claim(
tmp_path: Path,
) -> None:
"""异步 activate 的同步 claim 区间也必须受同一关闭 barrier 保护。"""
adapter = _AsyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
check_entered = threading.Event()
check_release = threading.Event()
shutdown_returned = threading.Event()
returned_before_release = []
closer_threads = []
original_ensure_open = runtime._ensure_open
first_check = True
check_lock = threading.Lock()
def gated_ensure_open() -> None:
nonlocal first_check
original_ensure_open()
with check_lock:
should_wait = first_check
first_check = False
if should_wait:
check_entered.set()
assert check_release.wait(timeout=5)
def shutdown() -> None:
asyncio.run(runtime.shutdown_async(reason="application_shutdown"))
shutdown_returned.set()
def coordinate_shutdown() -> None:
assert check_entered.wait(timeout=5)
closer = threading.Thread(target=shutdown)
closer_threads.append(closer)
closer.start()
returned_before_release.append(shutdown_returned.wait(timeout=0.1))
check_release.set()
coordinator = threading.Thread(target=coordinate_shutdown)
coordinator.start()
with patch.object(runtime, "_ensure_open", side_effect=gated_ensure_open):
activate_task = asyncio.create_task(
runtime.activate_async("sample.capability", reason="preclaim_race")
)
await adapter.start_entered.wait()
await asyncio.to_thread(coordinator.join, 5)
assert returned_before_release == [False]
assert not shutdown_returned.is_set()
adapter.start_release.set()
try:
await activate_task
except CapabilityRuntimeClosedError:
pass
await asyncio.to_thread(closer_threads[0].join, 5)
assert shutdown_returned.is_set()
assert runtime.get_running("sample.capability") is None
@pytest.mark.asyncio
async def test_async_adapter_uses_same_single_flight_state_machine(tmp_path: Path) -> None:
"""异步回调等待不能阻塞事件循环,并发调用共享同一 generation。"""
adapter = _AsyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
first = asyncio.create_task(runtime.activate_async("sample.capability", reason="first"))
await adapter.start_entered.wait()
second = asyncio.create_task(runtime.activate_async("sample.capability", reason="second"))
await asyncio.sleep(0)
assert runtime.get_running("sample.capability") is None
adapter.start_release.set()
first_instance, second_instance = await asyncio.gather(first, second)
assert first_instance is second_instance
assert adapter.materialize_calls == 1
assert adapter.start_calls == 1
assert runtime.snapshot("sample.capability").generation == 1
@pytest.mark.asyncio
async def test_stop_async_failure_retains_ownership_for_explicit_retry(tmp_path: Path) -> None:
"""异步 stop 失败后只能重试释放同一实例,不能直接启动新实例。"""
adapter = _AsyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
initial = asyncio.create_task(runtime.activate_async("sample.capability", reason="initial"))
await adapter.start_entered.wait()
adapter.start_release.set()
instance = await initial
adapter.fail_stop = True
with pytest.raises(CapabilityOperationError, match="async stop failed"):
await runtime.stop_async("sample.capability", reason="configuration_removed")
with pytest.raises(CapabilityOperationError, match="async stop failed"):
await runtime.activate_async("sample.capability", reason="unsafe_retry", retry=True)
assert adapter.create_calls == 1
adapter.fail_stop = False
await runtime.stop_async("sample.capability", reason="stop_retry")
assert adapter.stop_instances == [instance, instance, instance]
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPED
@pytest.mark.asyncio
async def test_async_reload_uses_reloading_state_and_hides_candidate(tmp_path: Path) -> None:
"""异步 reload 与同步入口遵守相同状态和发布边界。"""
adapter = _AsyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
initial = asyncio.create_task(runtime.activate_async("sample.capability", reason="initial"))
await adapter.start_entered.wait()
adapter.start_release.set()
old_instance = await initial
adapter.start_entered = asyncio.Event()
adapter.start_release = asyncio.Event()
reload_task = asyncio.create_task(
runtime.reload_async("sample.capability", reason="config_changed")
)
await adapter.start_entered.wait()
assert runtime.get_running("sample.capability") is None
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.RELOADING
adapter.start_release.set()
new_instance = await reload_task
assert new_instance is not old_instance
assert runtime.get_running("sample.capability") is new_instance
assert runtime.snapshot("sample.capability").generation == 2
@pytest.mark.asyncio
async def test_failed_async_reload_cleans_candidate_and_enters_failed(tmp_path: Path) -> None:
"""异步 reload 失败与同步入口一致,不发布半初始化候选。"""
adapter = _AsyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
initial = asyncio.create_task(runtime.activate_async("sample.capability", reason="initial"))
await adapter.start_entered.wait()
adapter.start_release.set()
await initial
adapter.start_entered = asyncio.Event()
adapter.start_release = asyncio.Event()
adapter.fail_start = True
reload_task = asyncio.create_task(
runtime.reload_async("sample.capability", reason="config_changed")
)
await adapter.start_entered.wait()
adapter.start_release.set()
with pytest.raises(CapabilityOperationError, match="async start failed"):
await reload_task
snapshot = runtime.snapshot("sample.capability")
assert snapshot.lifecycle is CapabilityLifecycleState.FAILED
assert snapshot.visible is False
assert adapter.cleanup_calls == 1
@pytest.mark.asyncio
async def test_async_reload_stop_failure_retains_previous_without_new_create(
tmp_path: Path,
) -> None:
"""异步 reload 也必须保留未释放旧实例并禁止创建第二份资源。"""
adapter = _AsyncAdapter()
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
initial = asyncio.create_task(runtime.activate_async("sample.capability", reason="initial"))
await adapter.start_entered.wait()
adapter.start_release.set()
old_instance = await initial
adapter.fail_stop = True
with pytest.raises(CapabilityOperationError, match="async stop failed"):
await runtime.reload_async("sample.capability", reason="config_changed")
assert runtime.get_running("sample.capability") is None
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.FAILED
assert adapter.stop_instances == [old_instance]
assert adapter.create_calls == 1
assert adapter.start_calls == 1
with pytest.raises(CapabilityOperationError, match="重试 stop"):
await runtime.activate_async("sample.capability", reason="implicit_retry")
adapter.fail_stop = False
adapter.start_entered = asyncio.Event()
adapter.start_release = asyncio.Event()
adapter.stop_entered = asyncio.Event()
adapter.stop_release = asyncio.Event()
recovery = asyncio.create_task(
runtime.activate_async(
"sample.capability",
reason="recover_after_reload",
retry=True,
)
)
await adapter.stop_entered.wait()
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPING
assert runtime.get_running("sample.capability") is None
assert adapter.create_calls == 1
adapter.stop_release.set()
await adapter.start_entered.wait()
adapter.start_release.set()
replacement = await recovery
assert adapter.stop_instances == [old_instance, old_instance]
assert replacement is not old_instance
assert adapter.create_calls == 2
def test_one_failed_capability_does_not_remove_specs_or_block_other_capabilities(
tmp_path: Path,
) -> None:
"""单项失败只改变自身状态,Registry 中的其它声明仍可继续运行。"""
first_dir = tmp_path / "first"
second_dir = tmp_path / "second"
first_dir.mkdir()
second_dir.mkdir()
(first_dir / "capability.toml").write_text(_MANIFEST.strip() + "\n", encoding="utf-8")
(second_dir / "capability.toml").write_text(
_MANIFEST.replace("sample.capability", "other.capability")
.replace("sample_implementation", "other_implementation")
.strip()
+ "\n",
encoding="utf-8",
)
registry = CapabilityRegistry.discover(
roots=[tmp_path],
kinds={"sample"},
selector_schemas={},
)
adapter = _SyncAdapter()
runtime = CapabilityRuntime(registry, adapters={"sample": adapter})
adapter.fail_start = True
with pytest.raises(CapabilityOperationError):
runtime.activate("sample.capability", reason="fail")
adapter.fail_start = False
other = runtime.activate("other.capability", reason="continue")
assert other.started is True
assert {spec.id for spec in runtime.list_specs()} == {
"sample.capability",
"other.capability",
}
assert runtime.snapshot("sample.capability").error == "start failed"
@pytest.mark.asyncio
async def test_sync_and_async_entrypoints_reject_wrong_adapter_mode(tmp_path: Path) -> None:
"""入口与 adapter 执行模型不匹配时应在执行回调前失败。"""
async_runtime = CapabilityRuntime(
_registry(tmp_path / "async"),
adapters={"sample": _AsyncAdapter()},
)
with pytest.raises(CapabilityAdapterModeError):
async_runtime.activate("sample.capability", reason="wrong_mode")
sync_runtime = CapabilityRuntime(
_registry(tmp_path / "sync"),
adapters={"sample": _SyncAdapter()},
)
with pytest.raises(CapabilityAdapterModeError):
await sync_runtime.activate_async("sample.capability", reason="wrong_mode")
def test_adapter_mode_requires_declared_enum_member(tmp_path: Path) -> None:
"""并发模型必须显式声明 enum,不能依赖字符串相等的偶然兼容。"""
adapter = _SyncAdapter()
adapter.execution_mode = "sync"
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
with pytest.raises(CapabilityAdapterModeError):
runtime.activate("sample.capability", reason="invalid_mode")
+20
View File
@@ -126,3 +126,23 @@ async def test_resolve_falls_back_to_qualname_when_name_mismatched(monkeypatch):
assert wrapper.__name__ == "wrapper"
assert instance.reload_count == 1
def test_externally_managed_reload_class_does_not_register_listener(monkeypatch):
"""外部统一管理配置生命周期时,Mixin 保留重载能力但不重复绑定事件。"""
registrations = []
monkeypatch.setattr(
eventmanager,
"add_event_listener",
lambda *args, **kwargs: registrations.append((args, kwargs)),
)
class _ExternallyManagedReloadRecorder(ConfigReloadMixin):
CONFIG_RELOAD_MANAGED_EXTERNALLY = True
CONFIG_WATCH = {"TEST_RELOAD_KEY"}
def on_config_changed(self):
pass
assert registrations == []
assert "handle_config_changed" not in _ExternallyManagedReloadRecorder.__dict__
+160
View File
@@ -0,0 +1,160 @@
"""事件调度订阅快照的并发回归测试。"""
import pytest
from app.runtime.events import Event, eventmanager
from app.schemas.types import ChainEventType, EventType
class _ImmediateExecutor:
"""在当前线程执行广播 handler,使订阅变更精确发生在调度迭代期间。"""
@staticmethod
def submit(func, *args, **kwargs):
return func(*args, **kwargs)
@pytest.fixture
def isolated_eventmanager(monkeypatch):
"""隔离全局事件总线的订阅表和广播执行器。"""
monkeypatch.setattr(
eventmanager,
"_EventManager__broadcast_subscribers",
{},
)
monkeypatch.setattr(
eventmanager,
"_EventManager__chain_subscribers",
{},
)
monkeypatch.setattr(
eventmanager,
"_EventManager__handler_instance_resolvers",
{},
)
monkeypatch.setattr(
eventmanager,
"_EventManager__executor",
_ImmediateExecutor(),
)
return eventmanager
def test_broadcast_dispatch_uses_subscription_snapshot(isolated_eventmanager):
"""广播事件中新增或移除的 handler 从下一个事件开始生效。"""
calls = []
def late_handler(_event):
calls.append("late")
def removed_handler(_event):
calls.append("removed")
def mutating_handler(_event):
calls.append("mutating")
isolated_eventmanager.remove_event_listener(
EventType.ConfigChanged,
removed_handler,
)
isolated_eventmanager.add_event_listener(
EventType.ConfigChanged,
late_handler,
)
isolated_eventmanager.add_event_listener(
EventType.ConfigChanged,
mutating_handler,
)
isolated_eventmanager.add_event_listener(
EventType.ConfigChanged,
removed_handler,
)
dispatch = isolated_eventmanager._EventManager__dispatch_broadcast_event
dispatch(Event(EventType.ConfigChanged, {}))
assert calls == ["mutating", "removed"]
calls.clear()
dispatch(Event(EventType.ConfigChanged, {}))
assert calls == ["mutating", "late"]
def test_sync_chain_dispatch_uses_subscription_snapshot(isolated_eventmanager):
"""同步链式事件中的订阅变更不影响当前处理器序列。"""
calls = []
def late_handler(_event):
calls.append("late")
def removed_handler(_event):
calls.append("removed")
def mutating_handler(_event):
calls.append("mutating")
isolated_eventmanager.remove_event_listener(
ChainEventType.NameRecognize,
removed_handler,
)
isolated_eventmanager.add_event_listener(
ChainEventType.NameRecognize,
late_handler,
)
isolated_eventmanager.add_event_listener(
ChainEventType.NameRecognize,
mutating_handler,
)
isolated_eventmanager.add_event_listener(
ChainEventType.NameRecognize,
removed_handler,
)
dispatch = isolated_eventmanager._EventManager__dispatch_chain_event
assert dispatch(Event(ChainEventType.NameRecognize, {})) is True
assert calls == ["mutating", "removed"]
calls.clear()
assert dispatch(Event(ChainEventType.NameRecognize, {})) is True
assert calls == ["mutating", "late"]
@pytest.mark.asyncio
async def test_async_chain_dispatch_uses_subscription_snapshot(
isolated_eventmanager,
):
"""异步链式事件中的订阅变更不影响当前处理器序列。"""
calls = []
async def late_handler(_event):
calls.append("late")
async def removed_handler(_event):
calls.append("removed")
async def mutating_handler(_event):
calls.append("mutating")
isolated_eventmanager.remove_event_listener(
ChainEventType.NameRecognize,
removed_handler,
)
isolated_eventmanager.add_event_listener(
ChainEventType.NameRecognize,
late_handler,
)
isolated_eventmanager.add_event_listener(
ChainEventType.NameRecognize,
mutating_handler,
)
isolated_eventmanager.add_event_listener(
ChainEventType.NameRecognize,
removed_handler,
)
dispatch = isolated_eventmanager._EventManager__dispatch_chain_event_async
assert await dispatch(Event(ChainEventType.NameRecognize, {})) is True
assert calls == ["mutating", "removed"]
calls.clear()
assert await dispatch(Event(ChainEventType.NameRecognize, {})) is True
assert calls == ["mutating", "late"]
+1 -1
View File
@@ -364,7 +364,7 @@ def _patch_module_shutdown_dependencies(monkeypatch) -> dict:
"""替换 stop_modules 的资源所有者,避免测试启动真实后台服务"""
dependencies = {}
for name, method_name in (
("ModuleManager", "stop"),
("ModuleManager", "shutdown"),
("EventManager", "stop"),
("DisplayHelper", "stop"),
("DohHelper", "shutdown"),
@@ -0,0 +1,930 @@
"""Host Module Adapter 对 Capability Runtime 的兼容合同测试。"""
from __future__ import annotations
import importlib
import os
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Iterator
from unittest.mock import Mock
import pytest
from app.db.oper.systemconfig import SystemConfigOper
from app.foundation.singleton import Singleton
from app.runtime.capabilities.errors import CapabilityRuntimeClosedError
from app.runtime.capabilities.model import SelectorSchema
from app.runtime.capabilities.registry import CapabilityRegistry
from app.runtime.events import Event, EventHandlerBinding, eventmanager
from app.runtime.extensions import module_manager as module_manager_extension
from app.runtime.extensions.module_manager import ModuleManager
from app.schemas import ConfigChangeEventData
from app.schemas.types import EventType
_SAMPLE_MANIFEST = """
schema_version = 1
id = "SampleModule"
kind = "host_module"
entrypoint = "fixture_sample_module:SampleModule"
depends_on = []
[metadata]
name = "Sample"
type = "notification"
subtype = "Telegram"
priority = 10
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "sample"
enabled_field = "enabled"
"""
_OTHER_MANIFEST = """
schema_version = 1
id = "OtherModule"
kind = "host_module"
entrypoint = "fixture_other_module:OtherModule"
depends_on = []
[metadata]
name = "Other"
type = "notification"
subtype = "Telegram"
priority = 20
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "other"
enabled_field = "enabled"
"""
_MODULE_SOURCE = """
class {class_name}:
instances = []
def __init__(self):
self.events = ["create"]
type(self).instances.append(self)
def init_module(self):
self.events.append("start")
def stop(self):
self.events.append("stop")
def test(self):
return True, "ok"
def capability_method(self):
return "handled"
@staticmethod
def get_name():
return "{name}"
@staticmethod
def get_type():
return "notification"
@staticmethod
def get_subtype():
return "Telegram"
@staticmethod
def get_priority():
return {priority}
"""
def _write_capability(root: Path, directory: str, manifest: str) -> None:
"""写入一个合成 Host Module 声明。"""
capability_dir = root / directory
capability_dir.mkdir(parents=True)
(capability_dir / "capability.toml").write_text(
manifest.strip() + "\n",
encoding="utf-8",
)
def _build_registry(root: Path) -> CapabilityRegistry:
"""用生产 schema 构造只包含两个合成模块的 Registry。"""
_write_capability(root, "sample", _SAMPLE_MANIFEST)
_write_capability(root, "other", _OTHER_MANIFEST)
return CapabilityRegistry.discover(
roots=[root],
kinds={"host_module"},
selector_schemas={
"system_config_item": SelectorSchema(
required_fields=frozenset({
"key",
"match_field",
"match_value",
"enabled_field",
}),
),
"setting_truthy": SelectorSchema(
required_fields=frozenset({"key"}),
),
},
)
def _config_changed_listeners() -> dict:
"""读取 ConfigChanged 监听快照,用于验证全局测试状态完整恢复。"""
subscribers = getattr(eventmanager, "_EventManager__broadcast_subscribers")
return dict(subscribers.get(EventType.ConfigChanged, {}))
def _run_real_host_module_check(tmp_path: Path, body: str) -> None:
"""在隔离后端和进程内网络守卫下执行真实 Host Module 合同检查。"""
project_root = Path(__file__).parents[1]
prelude = r"""
import ipaddress
import socket
import sys
network_attempts = []
allowed_hosts = {"127.0.0.1", "::1", "localhost", "0.0.0.0", "::", ""}
real_getaddrinfo = socket.getaddrinfo
real_connect = socket.socket.connect
def is_allowed_host(host):
normalized = host.decode() if isinstance(host, (bytes, bytearray)) else host
if normalized is None or normalized in allowed_hosts:
return True
try:
address = ipaddress.ip_address(str(normalized).split("%", 1)[0])
return address.is_loopback or address.is_unspecified
except ValueError:
return False
def block_network(operation, host):
network_attempts.append((operation, host))
raise AssertionError(f"Host Module 合同测试禁止真实出站:{operation} {host!r}")
def guarded_getaddrinfo(host, *args, **kwargs):
if not is_allowed_host(host):
block_network("DNS", host)
return real_getaddrinfo(host, *args, **kwargs)
def guarded_connect(sock, address):
if isinstance(address, tuple) and address and not is_allowed_host(address[0]):
block_network("socket", address[0])
return real_connect(sock, address)
socket.getaddrinfo = guarded_getaddrinfo
socket.socket.connect = guarded_connect
from app.testing.bootstrap import prepare_backend
prepare_backend()
"""
code = f"{prelude}\n{body}\nassert network_attempts == [], network_attempts\n"
env = os.environ.copy()
env["CONFIG_DIR"] = str(tmp_path / "config")
env["PYTHONPATH"] = str(project_root)
result = subprocess.run(
[sys.executable, "-c", code],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=180,
check=False,
)
assert result.returncode == 0, (
f"真实 Host Module 合同检查失败:\nstdout:\n{result.stdout[-4000:]}\n"
f"stderr:\n{result.stderr[-8000:]}"
)
@pytest.fixture
def module_manager_harness(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[SimpleNamespace]:
"""用合成声明和内存配置隔离 ModuleManager 单例。"""
source_root = tmp_path / "source"
source_root.mkdir()
(source_root / "fixture_sample_module.py").write_text(
_MODULE_SOURCE.format(class_name="SampleModule", name="Sample", priority=10),
encoding="utf-8",
)
(source_root / "fixture_other_module.py").write_text(
_MODULE_SOURCE.format(class_name="OtherModule", name="Other", priority=20),
encoding="utf-8",
)
monkeypatch.syspath_prepend(str(source_root))
registry = _build_registry(tmp_path / "capabilities")
monkeypatch.setattr(
module_manager_extension,
"build_host_module_registry",
lambda: registry,
)
config_values = {"Notifications": []}
def get_config(_self, key=None):
key_value = getattr(key, "value", key)
if key_value is None:
return dict(config_values)
return config_values.get(key_value)
monkeypatch.setattr(SystemConfigOper, "get", get_config)
singleton_key = (ModuleManager, (), frozenset())
previous_manager = Singleton._instances.pop(singleton_key, None)
resolver_attr = "_EventManager__handler_instance_resolvers"
previous_resolvers = dict(getattr(eventmanager, resolver_attr))
previous_config_changed_listeners = _config_changed_listeners()
for module_name in ("fixture_sample_module", "fixture_other_module"):
sys.modules.pop(module_name, None)
manager = ModuleManager()
restored = False
def restore() -> None:
"""撤销 Manager 构造写入的单例、resolver 和事件监听器。"""
nonlocal restored
if restored:
return
try:
manager.shutdown()
except (AttributeError, CapabilityRuntimeClosedError):
pass
Singleton._instances.pop(singleton_key, None)
if previous_manager is not None:
Singleton._instances[singleton_key] = previous_manager
setattr(eventmanager, resolver_attr, previous_resolvers)
subscribers = getattr(eventmanager, "_EventManager__broadcast_subscribers")
if previous_config_changed_listeners:
subscribers[EventType.ConfigChanged] = dict(
previous_config_changed_listeners
)
else:
subscribers.pop(EventType.ConfigChanged, None)
for module_name in ("fixture_sample_module", "fixture_other_module"):
sys.modules.pop(module_name, None)
restored = True
try:
yield SimpleNamespace(
manager=manager,
config_values=config_values,
previous_config_changed_listeners=previous_config_changed_listeners,
restore=restore,
)
finally:
restore()
def _enable_sample(config_values: dict) -> None:
"""写入可通过 sample selector 的最小合法通知配置。"""
config_values["Notifications"] = [
{
"name": "sample",
"type": "sample",
"config": {},
"switchs": [],
"enabled": True,
}
]
def test_harness_restores_module_manager_config_listener(
module_manager_harness,
) -> None:
"""Fixture teardown 不能把临时 Manager 的 bound listener 留在全局事件总线。"""
manager = module_manager_harness.manager
current_listeners = _config_changed_listeners()
assert current_listeners != (
module_manager_harness.previous_config_changed_listeners
)
assert any(
getattr(listener, "__self__", None) is manager
for listener in current_listeners.values()
)
module_manager_harness.restore()
assert _config_changed_listeners() == (
module_manager_harness.previous_config_changed_listeners
)
def test_specs_are_lightweight_and_do_not_materialize_modules(
module_manager_harness,
) -> None:
"""ModuleManager 的声明视图不能解析任何 Host Module 实现。"""
manager = module_manager_harness.manager
specs = manager.list_specs()
assert manager.get_specs() == specs
assert [spec.id for spec in specs] == ["OtherModule", "SampleModule"]
assert [spec.metadata["name"] for spec in specs] == ["Other", "Sample"]
assert "fixture_sample_module" not in sys.modules
assert "fixture_other_module" not in sys.modules
assert manager.get_running_module("SampleModule") is None
def test_get_module_materializes_one_canonical_class_without_starting_it(
module_manager_harness,
) -> None:
"""兼容查询返回 canonical class,但不创建或启动资源。"""
manager = module_manager_harness.manager
module_class = manager.get_module("SampleModule")
canonical_class = importlib.import_module(
"fixture_sample_module"
).SampleModule
assert module_class is canonical_class
assert manager.get_module("SampleModule") is canonical_class
assert canonical_class.instances == []
assert manager.get_running_module("SampleModule") is None
assert "fixture_other_module" not in sys.modules
def test_get_modules_materializes_all_real_classes_without_starting_them(
module_manager_harness,
) -> None:
"""旧 get_modules 合同保留真实 class 字典,不返回代理或隐式激活。"""
manager = module_manager_harness.manager
modules = manager.get_modules()
sample_module = importlib.import_module("fixture_sample_module")
other_module = importlib.import_module("fixture_other_module")
assert modules == {
"OtherModule": other_module.OtherModule,
"SampleModule": sample_module.SampleModule,
}
assert sample_module.SampleModule.instances == []
assert other_module.OtherModule.instances == []
assert manager.get_running_module("SampleModule") is None
assert manager.get_running_module("OtherModule") is None
def test_config_reconcile_reload_and_stop_preserve_manager_contract(
module_manager_harness,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""配置激活、全量 reload 与可重启 stop 保持同步可观察顺序。"""
manager = module_manager_harness.manager
_enable_sample(module_manager_harness.config_values)
manager.load_modules()
first = manager.get_running_module("SampleModule")
assert first is not None
assert first.events == ["create", "start"]
send_event = Mock()
monkeypatch.setattr(eventmanager, "send_event", send_event)
manager.reload()
second = manager.get_running_module("SampleModule")
assert second is not None
assert second is not first
assert first.events == ["create", "start", "stop"]
assert second.events == ["create", "start"]
send_event.assert_called_once_with(etype=EventType.ModuleReload, data={})
manager.stop()
assert manager.get_running_module("SampleModule") is None
assert second.events == ["create", "start", "stop"]
manager.load_modules()
restarted = manager.get_running_module("SampleModule")
assert restarted is not None
assert restarted is not second
assert restarted.events == ["create", "start"]
module_manager_harness.config_values["Notifications"] = []
manager.load_modules()
assert manager.get_running_module("SampleModule") is None
assert restarted.events == ["create", "start", "stop"]
def test_config_event_reloads_same_instance_and_tracks_selector_changes(
module_manager_harness,
) -> None:
"""配置事件由 Host Adapter 唯一协调,并保留模块实例内的重载状态。"""
manager = module_manager_harness.manager
_enable_sample(module_manager_harness.config_values)
manager.load_modules()
running = manager.get_running_module("SampleModule")
manager.handle_config_changed(
Event(
EventType.ConfigChanged,
ConfigChangeEventData(key="Notifications"),
)
)
assert manager.get_running_module("SampleModule") is running
assert running.events == ["create", "start", "stop", "start"]
module_manager_harness.config_values["Notifications"] = []
manager.handle_config_changed(
Event(
EventType.ConfigChanged,
ConfigChangeEventData(key="Notifications"),
)
)
assert manager.get_running_module("SampleModule") is None
assert running.events == ["create", "start", "stop", "start", "stop"]
def test_shutdown_is_irreversible(module_manager_harness) -> None:
"""shutdown 撤销全部可见实例,并拒绝通过 load_modules 再次启动。"""
manager = module_manager_harness.manager
_enable_sample(module_manager_harness.config_values)
manager.load_modules()
running = manager.get_running_module("SampleModule")
assert running is not None
manager.shutdown()
assert manager.get_running_module("SampleModule") is None
assert running.events == ["create", "start", "stop"]
manager.load_modules()
assert manager.get_running_module("SampleModule") is None
assert type(running).instances == [running]
def test_all_real_host_modules_zero_arg_construct_without_starting_resources(
tmp_path: Path,
) -> None:
"""每份真实 manifest 都必须能解析 canonical class 并零参数构造且不启动资源。"""
body = r"""
from app.runtime.extensions.host_module_adapter import (
HostModuleAdapter,
build_host_module_registry,
)
registry = build_host_module_registry()
specs = registry.list_specs()
assert len(specs) == 37
adapter = HostModuleAdapter()
lifecycle_events = []
instances = {}
def make_recorder(operation, capability_id):
def record(instance):
lifecycle_events.append((operation, capability_id, id(instance)))
return record
for spec in specs:
implementation = adapter.materialize(spec)
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
assert implementation is getattr(sys.modules[module_name], symbol_name)
implementation.init_module = make_recorder("start", spec.id)
implementation.stop = make_recorder("stop", spec.id)
instance = adapter.create(spec, implementation, generation=1)
assert type(instance) is implementation
instances[spec.id] = instance
assert set(instances) == {spec.id for spec in specs}
assert lifecycle_events == []
"""
_run_real_host_module_check(tmp_path, body)
def test_real_manifest_inventory_drives_full_module_manager_lifecycle(
tmp_path: Path,
) -> None:
"""真实声明自动驱动全量激活、原实例重载、禁用停止和不可逆关闭门禁。"""
body = r"""
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.capabilities.model import ActivationPolicy
from app.runtime.config import settings
from app.runtime.events import Event
from app.runtime.extensions.host_module_adapter import (
HostModuleAdapter,
build_host_module_registry,
)
from app.schemas import ConfigChangeEventData
from app.schemas.types import EventType
registry = build_host_module_registry()
specs = registry.list_specs()
assert len(specs) == 37
spec_by_id = {spec.id: spec for spec in specs}
events = {spec.id: [] for spec in specs}
adapter = HostModuleAdapter()
def make_recorder(operation, capability_id):
def record(instance):
events[capability_id].append((operation, id(instance)))
return record
for spec in specs:
implementation = adapter.materialize(spec)
implementation.init_module = make_recorder("start", spec.id)
implementation.stop = make_recorder("stop", spec.id)
config_values = {}
enabled_service_values = {}
selector_keys = set()
configured_ids = set()
for spec in specs:
if spec.activation is not ActivationPolicy.WHEN_CONFIGURED:
continue
configured_ids.add(spec.id)
selector = spec.selector
assert selector is not None
key = str(selector.config["key"])
selector_keys.add(key)
if selector.kind == "setting_truthy":
setattr(settings, key, f"enabled:{spec.id}")
elif selector.kind == "system_config_item":
enabled_service_values.setdefault(key, []).append({
"name": f"contract-{spec.id}",
"type": selector.config["match_value"],
"config": {},
"enabled": True,
})
else:
raise AssertionError(f"未覆盖的 Host Module selector{selector.kind}")
config_values.update({key: list(value) for key, value in enabled_service_values.items()})
def get_config(_self, key=None):
key_value = getattr(key, "value", key)
if key_value is None:
return dict(config_values)
return config_values.get(key_value)
SystemConfigOper.get = get_config
from app.runtime.extensions.module_manager import ModuleManager
manager = ModuleManager()
bootstrap_ids = {
spec.id
for spec in specs
if spec.activation is ActivationPolicy.BOOTSTRAP
}
initial_ids = bootstrap_ids | configured_ids
assert bootstrap_ids
assert configured_ids
assert initial_ids == {spec.id for spec in specs}
initial_instances = {
capability_id: manager.get_running_module(capability_id)
for capability_id in initial_ids
}
assert all(initial_instances.values())
assert {
capability_id: [operation for operation, _instance_id in events[capability_id]]
for capability_id in initial_ids
} == {capability_id: ["start"] for capability_id in initial_ids}
watch_keys = {key for spec in specs for key in spec.watch}
watched_ids = {
spec.id
for spec in specs
if spec.id in initial_ids and watch_keys.intersection(spec.watch)
}
manager.handle_config_changed(
Event(
EventType.ConfigChanged,
ConfigChangeEventData(key=watch_keys),
)
)
for capability_id, initial_instance in initial_instances.items():
assert manager.get_running_module(capability_id) is initial_instance
operations = [operation for operation, _instance_id in events[capability_id]]
expected = ["start", "stop", "start"] if capability_id in watched_ids else ["start"]
assert operations == expected, (capability_id, operations)
assert {
instance_id for _operation, instance_id in events[capability_id]
} == {id(initial_instance)}
for spec in specs:
if spec.id not in configured_ids:
continue
selector = spec.selector
key = str(selector.config["key"])
if selector.kind == "setting_truthy":
setattr(settings, key, False)
for key in enabled_service_values:
config_values[key] = []
manager.handle_config_changed(
Event(
EventType.ConfigChanged,
ConfigChangeEventData(key=selector_keys),
)
)
for capability_id in configured_ids:
assert manager.get_running_module(capability_id) is None
assert [operation for operation, _instance_id in events[capability_id]] == [
"start",
"stop",
"start",
"stop",
]
for capability_id in bootstrap_ids:
assert manager.get_running_module(capability_id) is initial_instances[capability_id]
manager.shutdown()
assert all(manager.get_running_module(spec.id) is None for spec in specs)
events_after_shutdown = {
capability_id: list(capability_events)
for capability_id, capability_events in events.items()
}
for spec in specs:
if spec.id not in configured_ids:
continue
selector = spec.selector
key = str(selector.config["key"])
if selector.kind == "setting_truthy":
setattr(settings, key, f"re-enabled:{spec.id}")
for key, value in enabled_service_values.items():
config_values[key] = list(value)
manager.load_modules()
manager.handle_config_changed(
Event(
EventType.ConfigChanged,
ConfigChangeEventData(key=watch_keys),
)
)
assert all(manager.get_running_module(spec.id) is None for spec in specs)
assert events == events_after_shutdown
assert set(spec_by_id) == set(events)
"""
_run_real_host_module_check(tmp_path, body)
def test_default_config_keeps_every_manifest_configured_entrypoint_unimported(
tmp_path: Path,
) -> None:
"""默认配置惰性边界由全部 when-configured manifest 自动生成。"""
body = r"""
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.capabilities.model import ActivationPolicy
from app.runtime.config import settings
from app.runtime.extensions.host_module_adapter import (
HostModuleAdapter,
build_host_module_registry,
)
registry = build_host_module_registry()
specs = registry.list_specs()
assert len(specs) == 37
configured_specs = tuple(
spec for spec in specs
if spec.activation is ActivationPolicy.WHEN_CONFIGURED
)
configured_modules = {
spec.entrypoint.split(":", maxsplit=1)[0]
for spec in configured_specs
}
assert configured_modules
assert configured_modules.isdisjoint(sys.modules)
for spec in configured_specs:
selector = spec.selector
assert selector is not None
if selector.kind == "setting_truthy":
setattr(settings, str(selector.config["key"]), False)
SystemConfigOper.get = lambda _self, key=None: {} if key is None else []
adapter = HostModuleAdapter()
for spec in specs:
if spec.activation is not ActivationPolicy.BOOTSTRAP:
continue
implementation = adapter.materialize(spec)
implementation.init_module = lambda _self: None
implementation.stop = lambda _self: None
assert configured_modules.isdisjoint(sys.modules)
from app.runtime.extensions.module_manager import ModuleManager
manager = ModuleManager()
assert manager.get_specs() == manager.list_specs()
assert {spec.id for spec in manager.list_specs()} == {spec.id for spec in specs}
assert all(manager.get_running_module(spec.id) is None for spec in configured_specs)
assert configured_modules.isdisjoint(sys.modules)
manager.shutdown()
assert configured_modules.isdisjoint(sys.modules)
"""
_run_real_host_module_check(tmp_path, body)
def test_event_resolver_uses_exact_class_and_blocks_stopped_owner_fallback(
module_manager_harness,
) -> None:
"""同名 class 不能冒充 owner;已停止 owner 必须返回 Binding(None)。"""
manager = module_manager_harness.manager
_enable_sample(module_manager_harness.config_values)
manager.load_modules()
module_class = manager.get_module("SampleModule")
running = manager.get_running_module("SampleModule")
active_binding = manager.resolve_event_handler_instance(module_class)
assert active_binding == EventHandlerBinding(
instance=running,
owner_name="Sample",
)
impostor = type("SampleModule", (), {})
impostor.__module__ = module_class.__module__
assert manager.resolve_event_handler_instance(impostor) is None
manager.stop()
stopped_binding = manager.resolve_event_handler_instance(module_class)
assert stopped_binding == EventHandlerBinding(
instance=None,
owner_name="Sample",
)
def test_default_modulelist_does_not_import_unconfigured_provider_sdks(
tmp_path: Path,
) -> None:
"""默认配置下构造 Manager 和查询模块列表都不能拉起重量 provider SDK。"""
project_root = Path(__file__).parents[1]
code = """
from app.testing.bootstrap import prepare_backend
prepare_backend()
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.config import settings
def empty_config(self, key=None):
return {} if key is None else []
SystemConfigOper.get = empty_config
settings.ACOUSTID_API_KEY = None
settings.FANART_API_KEY = None
from app.runtime.extensions.module_manager import ModuleManager
manager = ModuleManager()
assert len(manager.list_specs()) == 37
assert manager.get_specs() == manager.list_specs()
from app.api.endpoints.system import modulelist
response = modulelist(None)
assert len(response.data["modules"]) == 37
heavy_prefixes = (
"lark_oapi",
"slack_bolt",
"slack_sdk",
"discord",
"plexapi",
"telebot",
)
loaded = sorted(
name
for name in sys.modules
if any(name == prefix or name.startswith(prefix + ".") for prefix in heavy_prefixes)
)
assert loaded == [], loaded
manager.shutdown()
"""
env = os.environ.copy()
env["CONFIG_DIR"] = str(tmp_path / "config")
env["PYTHONPATH"] = str(project_root)
result = subprocess.run(
[sys.executable, "-c", "import sys\n" + code],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=180,
check=False,
)
assert result.returncode == 0, (
f"子进程模块发现失败:\nstdout:\n{result.stdout[-2000:]}\n"
f"stderr:\n{result.stderr[-4000:]}"
)
def test_lazy_boundary_annotations_are_reflectable_without_provider_sdks(
tmp_path: Path,
) -> None:
"""宿主公共注解可被反射,且反射过程不加载可选 provider SDK。"""
project_root = Path(__file__).parents[1]
code = """
from app.testing.bootstrap import prepare_backend
prepare_backend()
import sys
from typing import Any, Optional, get_type_hints
provider_prefixes = ("qbittorrentapi", "transmission_rpc", "pywebpush")
def loaded_provider_modules():
return sorted(
name
for name in sys.modules
if any(
name == prefix or name.startswith(prefix + ".")
for prefix in provider_prefixes
)
)
assert loaded_provider_modules() == []
from app.chain import ChainBase
from app.api.endpoints.message import WebPushError, is_webpush_subscription_gone
assert get_type_hints(ChainBase.torrent_files)["return"] == Optional[Any]
assert get_type_hints(is_webpush_subscription_gone)["error"] is WebPushError
assert loaded_provider_modules() == []
"""
env = os.environ.copy()
env["CONFIG_DIR"] = str(tmp_path / "config")
env["PYTHONPATH"] = str(project_root)
result = subprocess.run(
[sys.executable, "-c", code],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=180,
check=False,
)
assert result.returncode == 0, (
f"轻量注解反射失败:\nstdout:\n{result.stdout[-2000:]}\n"
f"stderr:\n{result.stderr[-4000:]}"
)
def test_manifest_metadata_matches_legacy_module_class_contract(tmp_path: Path) -> None:
"""manifest 投影必须与插件仍可调用的模块类 metadata 完全一致。"""
project_root = Path(__file__).parents[1]
code = """
from app.testing.bootstrap import prepare_backend
prepare_backend()
from app.db.oper.systemconfig import SystemConfigOper
SystemConfigOper.get = lambda self, key=None: {} if key is None else []
from app.runtime.config import settings
settings.ACOUSTID_API_KEY = None
settings.FANART_API_KEY = None
from app.runtime.extensions.module_manager import ModuleManager
manager = ModuleManager()
modules = manager.get_modules()
assert len(modules) == len(manager.list_specs()) == 37
for spec in manager.list_specs():
implementation = modules[spec.id]
assert implementation.get_name() == spec.metadata["name"]
assert implementation.get_type().value == spec.metadata["type"]
assert implementation.get_subtype().name == spec.metadata["subtype"]
assert implementation.get_priority() == spec.metadata["priority"]
manager.shutdown()
"""
env = os.environ.copy()
env["CONFIG_DIR"] = str(tmp_path / "config")
env["PYTHONPATH"] = str(project_root)
result = subprocess.run(
[sys.executable, "-c", "import sys\n" + code],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=180,
check=False,
)
assert result.returncode == 0, (
f"模块 metadata 兼容检查失败:\nstdout:\n{result.stdout[-2000:]}\n"
f"stderr:\n{result.stderr[-4000:]}"
)
+6 -3
View File
@@ -21,9 +21,12 @@ def test_navidrome_module_has_no_system_switch():
assert NavidromeModule().init_setting() is None
def test_navidrome_module_is_loaded_by_module_manager():
"""模块管理器应能加载 Navidrome,否则媒体服务器列表里不会出现该类型"""
assert "NavidromeModule" in ModuleManager()._running_modules
def test_navidrome_module_is_discovered_without_unconfigured_activation():
"""Navidrome 始终可发现,但没有启用配置时不应创建服务资源"""
manager = ModuleManager()
assert "NavidromeModule" in manager.get_module_ids()
assert manager.get_running_module("NavidromeModule") is None
def test_navidrome_module_ignores_non_music_media():
+41
View File
@@ -0,0 +1,41 @@
import threading
from app.foundation.singleton import Singleton, SingletonClass
def _construct_concurrently(singleton_type, count: int = 16):
"""让多个线程同时越过起跑线,放大首次构造竞态。"""
barrier = threading.Barrier(count)
instances = []
def construct() -> None:
barrier.wait()
instances.append(singleton_type())
threads = [threading.Thread(target=construct) for _ in range(count)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=5)
assert not thread.is_alive()
return instances
def test_parameterized_singleton_first_construction_is_single_flight():
"""同一参数的并发首次构造只能发布一个完整实例。"""
class _ParameterizedSingleton(metaclass=Singleton):
pass
instances = _construct_concurrently(_ParameterizedSingleton)
assert len({id(instance) for instance in instances}) == 1
def test_class_singleton_first_construction_is_single_flight():
"""按类单例的并发首次构造只能发布一个完整实例。"""
class _ClassSingleton(metaclass=SingletonClass):
pass
instances = _construct_concurrently(_ClassSingleton)
assert len({id(instance) for instance in instances}) == 1
+6 -12
View File
@@ -1,24 +1,18 @@
from unittest.mock import patch
from types import SimpleNamespace
from app.api.endpoints import system as system_endpoint
from app.runtime.localization import LocaleHelper
class _FakeDoubanModule:
"""构造带中文名称的模块类,模拟真实 DoubanModule。"""
@staticmethod
def get_name() -> str:
"""获取模块中文名称"""
return "豆瓣"
class _FakeModuleManager:
"""提供 system 模块接口测试所需的最小模块管理器。"""
def get_modules(self) -> dict:
"""返回模块字典"""
return {"DoubanModule": _FakeDoubanModule}
def list_specs(self) -> tuple:
"""返回 manifest 元数据视图。"""
return (
SimpleNamespace(id="DoubanModule", metadata={"name": "豆瓣"}),
)
def test(self, moduleid: str) -> tuple[bool, str]:
"""返回模块测试结果"""