mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
963 lines
37 KiB
Python
963 lines
37 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sys
|
|
import threading
|
|
import types
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
from unittest.mock import AsyncMock, 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 _SampleCapability:
|
|
pass
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolate_sample_entrypoints() -> Iterator[None]:
|
|
"""隔离合成 adapter 在 sys.modules 中公开的 canonical entrypoint。"""
|
|
module_names = ("sample_implementation", "other_implementation")
|
|
previous = {name: sys.modules[name] for name in module_names if name in sys.modules}
|
|
for name in module_names:
|
|
sys.modules.pop(name, None)
|
|
yield
|
|
for name in module_names:
|
|
sys.modules.pop(name, None)
|
|
if name in previous:
|
|
sys.modules[name] = previous[name]
|
|
|
|
|
|
def _materialize_sample(spec) -> type[_SampleCapability]:
|
|
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
|
|
module = types.ModuleType(module_name)
|
|
setattr(module, symbol_name, _SampleCapability)
|
|
sys.modules[module_name] = module
|
|
return _SampleCapability
|
|
|
|
|
|
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 _materialize_sample(spec)
|
|
|
|
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 _materialize_sample(spec)
|
|
|
|
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_materialize_rejects_missing_canonical_module(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""adapter 未加载 manifest 指定模块时不得发布替代实现。"""
|
|
adapter = _SyncAdapter()
|
|
runtime = CapabilityRuntime(
|
|
_registry(tmp_path),
|
|
adapters={"sample": adapter},
|
|
)
|
|
|
|
with patch.object(adapter, "materialize", return_value=_SampleCapability), pytest.raises(
|
|
CapabilityOperationError,
|
|
match="canonical 模块.*未加载",
|
|
):
|
|
runtime.materialize("sample.capability", reason="invalid_adapter")
|
|
|
|
snapshot = runtime.snapshot("sample.capability")
|
|
assert snapshot.materialization is CapabilityMaterializationState.FAILED
|
|
assert snapshot.lifecycle is CapabilityLifecycleState.DISCOVERED
|
|
|
|
|
|
def test_materialize_rejects_missing_canonical_symbol(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""adapter 未公开 manifest 指定符号时不得发布替代实现。"""
|
|
sys.modules["sample_implementation"] = types.ModuleType("sample_implementation")
|
|
adapter = _SyncAdapter()
|
|
runtime = CapabilityRuntime(
|
|
_registry(tmp_path),
|
|
adapters={"sample": adapter},
|
|
)
|
|
|
|
with patch.object(adapter, "materialize", return_value=_SampleCapability), pytest.raises(
|
|
CapabilityOperationError,
|
|
match="canonical 符号.*不存在",
|
|
):
|
|
runtime.materialize("sample.capability", reason="invalid_adapter")
|
|
|
|
snapshot = runtime.snapshot("sample.capability")
|
|
assert snapshot.materialization is CapabilityMaterializationState.FAILED
|
|
assert snapshot.lifecycle is CapabilityLifecycleState.DISCOVERED
|
|
|
|
|
|
def test_activate_revalidates_missing_canonical_module_on_retry(tmp_path: Path) -> None:
|
|
"""同步激活的物化合同失败不得被记为已解析并在重试时绕过。"""
|
|
adapter = _SyncAdapter()
|
|
runtime = CapabilityRuntime(
|
|
_registry(tmp_path),
|
|
adapters={"sample": adapter},
|
|
)
|
|
|
|
with patch.object(
|
|
adapter,
|
|
"materialize",
|
|
return_value=_SampleCapability,
|
|
) as materialize:
|
|
with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"):
|
|
runtime.activate("sample.capability", reason="invalid_adapter")
|
|
with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"):
|
|
runtime.activate(
|
|
"sample.capability",
|
|
reason="invalid_adapter_retry",
|
|
retry=True,
|
|
)
|
|
|
|
snapshot = runtime.snapshot("sample.capability")
|
|
assert snapshot.materialization is CapabilityMaterializationState.FAILED
|
|
assert snapshot.lifecycle is CapabilityLifecycleState.FAILED
|
|
assert adapter.create_calls == 0
|
|
assert materialize.call_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_activate_async_revalidates_missing_canonical_module_on_retry(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""异步激活的物化合同失败不得被记为已解析并在重试时绕过。"""
|
|
adapter = _AsyncAdapter()
|
|
runtime = CapabilityRuntime(
|
|
_registry(tmp_path),
|
|
adapters={"sample": adapter},
|
|
)
|
|
materialize = AsyncMock(return_value=_SampleCapability)
|
|
|
|
with patch.object(adapter, "materialize", new=materialize):
|
|
with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"):
|
|
await runtime.activate_async("sample.capability", reason="invalid_adapter")
|
|
with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"):
|
|
await runtime.activate_async(
|
|
"sample.capability",
|
|
reason="invalid_adapter_retry",
|
|
retry=True,
|
|
)
|
|
|
|
snapshot = runtime.snapshot("sample.capability")
|
|
assert snapshot.materialization is CapabilityMaterializationState.FAILED
|
|
assert snapshot.lifecycle is CapabilityLifecycleState.FAILED
|
|
assert adapter.create_calls == 0
|
|
assert materialize.await_count == 2
|
|
|
|
|
|
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")
|