mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
refactor(runtime): lazily activate host modules (#6331)
This commit is contained in:
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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__
|
||||
|
||||
@@ -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"]
|
||||
@@ -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:]}"
|
||||
)
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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]:
|
||||
"""返回模块测试结果"""
|
||||
|
||||
Reference in New Issue
Block a user