Merge remote-tracking branch 'origin/v3' into v3

# Conflicts:
#	tests/fixtures/architecture/dependency-baseline.json
This commit is contained in:
jxxghp
2026-08-23 15:44:35 +08:00
6 changed files with 197 additions and 39 deletions
+25
View File
@@ -21,6 +21,31 @@ async def run_in_threadpool(
return await run_sync(context.run, func, *args)
async def run_in_threadpool_to_completion(
func: Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> Any:
"""在线程调用取得终态后传播取消,避免提前释放仍在使用的执行容量。"""
worker_task = asyncio.create_task(run_in_threadpool(func, *args, **kwargs))
cancellation: asyncio.CancelledError | None = None
while not worker_task.done():
try:
await asyncio.wait({worker_task})
except asyncio.CancelledError as error:
cancellation = cancellation or error
continue
try:
result = worker_task.result()
except Exception as error:
if cancellation is not None:
raise cancellation from error
raise
if cancellation is not None:
raise cancellation
return result
def retry(ExceptionToCheck: Any,
tries: int = 3, delay: int = 3, backoff: int = 2, logger: Any = None):
"""
+2 -2
View File
@@ -7,7 +7,7 @@ from collections.abc import Callable, Mapping
from typing import Any, Protocol, cast
from app.foundation.reflection import ObjectUtils
from app.runtime.execution import run_in_threadpool
from app.runtime.execution import run_in_threadpool_to_completion
from app.runtime.log import logger
from app.runtime.observability import observe_duration, record_metric
from app.runtime.extensions.module.contracts import (
@@ -50,7 +50,7 @@ class ModuleInvocationDispatcher:
plugin_error_handler: ModuleErrorHandler,
system_error_handler: ModuleErrorHandler,
rate_limit_handler: ModuleErrorHandler,
async_function_runner: AsyncFunctionRunner = run_in_threadpool,
async_function_runner: AsyncFunctionRunner = run_in_threadpool_to_completion,
) -> None:
"""保存模块目录和策略回调,不主动发现或创建任何运行时资源。"""
self._module_catalog = module_catalog
+5 -5
View File
@@ -1,4 +1,4 @@
import asyncio
import inspect
import posixpath
import threading
from contextlib import contextmanager
@@ -13,6 +13,7 @@ from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
from app.foundation.crypto import RSAUtils
from app.foundation.singleton import Singleton
from app.foundation.version import compare_version
from app.runtime.execution import run_in_threadpool_to_completion
from app.runtime.log import logger
from app.runtime.observability import observe_compat_facade
from app.runtime.settings import RuntimeSettingsCompat
@@ -937,7 +938,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
async def async_run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:
"""
异步运行插件方法
异步运行插件方法,同步实现经受控线程入口执行
:param pid: 插件ID
:param method: 方法名
:param args: 参数
@@ -949,10 +950,9 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
if not hasattr(plugin, method):
return None
method_func = getattr(plugin, method)
if asyncio.iscoroutinefunction(method_func):
if inspect.iscoroutinefunction(method_func):
return await method_func(*args, **kwargs)
else:
return method_func(*args, **kwargs)
return await run_in_threadpool_to_completion(method_func, *args, **kwargs)
def get_plugin_ids(self) -> List[str]:
"""
+4 -32
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6478,
"edge_sha256": "d1c26c72787d07b411f6e047863bfb135005be8d3cf2212f2f03e0d8e0326283",
"edge_count": 6451,
"edge_sha256": "9075d8717e384580cd6a41bc36685438770db4a8d8f18c57e6c494f32937113a",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -1510,8 +1510,6 @@
"app.api.context -> app.application.subscription.delete",
"app.api.context -> app.application.subscription.identity",
"app.api.context -> app.application.subscription.mutation",
"app.api.context -> app.runtime",
"app.api.context -> app.runtime.tasks",
"app.api.context -> app.startup",
"app.api.context -> app.startup.context",
"app.api.dependencies.agent -> app.api",
@@ -1601,7 +1599,6 @@
"app.api.dependencies.subscription -> app.runtime",
"app.api.dependencies.subscription -> app.runtime.events",
"app.api.dependencies.subscription -> app.runtime.log",
"app.api.dependencies.subscription -> app.runtime.tasks",
"app.api.dependencies.subscription -> app.schemas",
"app.api.dependencies.subscription -> app.schemas.types",
"app.api.dependencies.subscription -> app.startup",
@@ -1685,7 +1682,6 @@
"app.api.endpoints.anthropic -> app.agent",
"app.api.endpoints.anthropic -> app.agent.runtime_loader",
"app.api.endpoints.anthropic -> app.api",
"app.api.endpoints.anthropic -> app.api.context",
"app.api.endpoints.anthropic -> app.api.endpoints",
"app.api.endpoints.anthropic -> app.api.endpoints.openai",
"app.api.endpoints.anthropic -> app.api.openai_utils",
@@ -1693,8 +1689,6 @@
"app.api.endpoints.anthropic -> app.api.presentation.sse",
"app.api.endpoints.anthropic -> app.application",
"app.api.endpoints.anthropic -> app.application.configuration",
"app.api.endpoints.anthropic -> app.runtime",
"app.api.endpoints.anthropic -> app.runtime.tasks",
"app.api.endpoints.anthropic -> app.schemas",
"app.api.endpoints.anthropic -> app.schemas.openai",
"app.api.endpoints.auth -> app.api",
@@ -1839,7 +1833,6 @@
"app.api.endpoints.history -> app.runtime.config",
"app.api.endpoints.history -> app.runtime.log",
"app.api.endpoints.history -> app.runtime.progress",
"app.api.endpoints.history -> app.runtime.tasks",
"app.api.endpoints.history -> app.schemas",
"app.api.endpoints.history -> app.schemas.common",
"app.api.endpoints.history -> app.schemas.history",
@@ -1952,7 +1945,6 @@
"app.api.endpoints.message -> app.adapters.web.security",
"app.api.endpoints.message -> app.adapters.web.security.access",
"app.api.endpoints.message -> app.api",
"app.api.endpoints.message -> app.api.context",
"app.api.endpoints.message -> app.api.dependencies",
"app.api.endpoints.message -> app.api.dependencies.agent",
"app.api.endpoints.message -> app.api.dependencies.auth",
@@ -1969,7 +1961,6 @@
"app.api.endpoints.message -> app.runtime.extensions",
"app.api.endpoints.message -> app.runtime.extensions.service_config",
"app.api.endpoints.message -> app.runtime.log",
"app.api.endpoints.message -> app.runtime.tasks",
"app.api.endpoints.message -> app.schemas",
"app.api.endpoints.message -> app.schemas.message",
"app.api.endpoints.message -> app.schemas.response",
@@ -2038,14 +2029,11 @@
"app.api.endpoints.openai -> app.agent.contracts",
"app.api.endpoints.openai -> app.agent.runtime_loader",
"app.api.endpoints.openai -> app.api",
"app.api.endpoints.openai -> app.api.context",
"app.api.endpoints.openai -> app.api.openai_utils",
"app.api.endpoints.openai -> app.api.presentation",
"app.api.endpoints.openai -> app.api.presentation.sse",
"app.api.endpoints.openai -> app.application",
"app.api.endpoints.openai -> app.application.configuration",
"app.api.endpoints.openai -> app.runtime",
"app.api.endpoints.openai -> app.runtime.tasks",
"app.api.endpoints.openai -> app.schemas",
"app.api.endpoints.openai -> app.schemas.openai",
"app.api.endpoints.openai -> app.schemas.types",
@@ -2060,7 +2048,6 @@
"app.api.endpoints.plugin -> app.adapters.web.security",
"app.api.endpoints.plugin -> app.adapters.web.security.access",
"app.api.endpoints.plugin -> app.api",
"app.api.endpoints.plugin -> app.api.context",
"app.api.endpoints.plugin -> app.api.dependencies",
"app.api.endpoints.plugin -> app.api.dependencies.auth",
"app.api.endpoints.plugin -> app.api.dependencies.plugin",
@@ -2082,7 +2069,6 @@
"app.api.endpoints.plugin -> app.runtime.extensions.plugin",
"app.api.endpoints.plugin -> app.runtime.extensions.plugin.contracts",
"app.api.endpoints.plugin -> app.runtime.log",
"app.api.endpoints.plugin -> app.runtime.tasks",
"app.api.endpoints.plugin -> app.schemas",
"app.api.endpoints.plugin -> app.schemas.common",
"app.api.endpoints.plugin -> app.schemas.exception",
@@ -2136,7 +2122,6 @@
"app.api.endpoints.site -> app.adapters.web.security",
"app.api.endpoints.site -> app.adapters.web.security.access",
"app.api.endpoints.site -> app.api",
"app.api.endpoints.site -> app.api.context",
"app.api.endpoints.site -> app.api.dependencies",
"app.api.endpoints.site -> app.api.dependencies.auth",
"app.api.endpoints.site -> app.api.dependencies.site",
@@ -2160,7 +2145,6 @@
"app.api.endpoints.site -> app.domain.site",
"app.api.endpoints.site -> app.runtime",
"app.api.endpoints.site -> app.runtime.log",
"app.api.endpoints.site -> app.runtime.tasks",
"app.api.endpoints.site -> app.schemas",
"app.api.endpoints.site -> app.schemas.common",
"app.api.endpoints.site -> app.schemas.response",
@@ -2196,7 +2180,6 @@
"app.api.endpoints.subscribe -> app.adapters.web.security",
"app.api.endpoints.subscribe -> app.adapters.web.security.access",
"app.api.endpoints.subscribe -> app.api",
"app.api.endpoints.subscribe -> app.api.context",
"app.api.endpoints.subscribe -> app.api.dependencies",
"app.api.endpoints.subscribe -> app.api.dependencies.auth",
"app.api.endpoints.subscribe -> app.api.dependencies.subscription",
@@ -2218,7 +2201,6 @@
"app.api.endpoints.subscribe -> app.domain.metainfo",
"app.api.endpoints.subscribe -> app.runtime",
"app.api.endpoints.subscribe -> app.runtime.events",
"app.api.endpoints.subscribe -> app.runtime.tasks",
"app.api.endpoints.subscribe -> app.schemas",
"app.api.endpoints.subscribe -> app.schemas.common",
"app.api.endpoints.subscribe -> app.schemas.event",
@@ -2365,12 +2347,9 @@
"app.api.endpoints.webhook -> app.adapters.web.security",
"app.api.endpoints.webhook -> app.adapters.web.security.access",
"app.api.endpoints.webhook -> app.api",
"app.api.endpoints.webhook -> app.api.context",
"app.api.endpoints.webhook -> app.api.response",
"app.api.endpoints.webhook -> app.chain",
"app.api.endpoints.webhook -> app.chain.webhook",
"app.api.endpoints.webhook -> app.runtime",
"app.api.endpoints.webhook -> app.runtime.tasks",
"app.api.endpoints.webhook -> app.schemas",
"app.api.endpoints.webhook -> app.schemas.response",
"app.api.endpoints.workflow -> app.adapters",
@@ -2576,8 +2555,6 @@
"app.application.mediaserver -> app.schemas.mediaserver",
"app.application.mediaserver -> app.schemas.system",
"app.application.mediaserver -> app.schemas.types",
"app.application.messaging.agent -> app.runtime",
"app.application.messaging.agent -> app.runtime.tasks",
"app.application.messaging.agent -> app.schemas",
"app.application.messaging.agent -> app.schemas.types",
"app.application.messaging.chat -> app.application",
@@ -3642,6 +3619,7 @@
"app.db.oper.passkey -> app.db.base",
"app.db.oper.passkey -> app.db.models",
"app.db.oper.passkey -> app.db.models.passkey",
"app.db.oper.passkey -> app.db.uow",
"app.db.oper.plugindata -> app.db",
"app.db.oper.plugindata -> app.db.base",
"app.db.oper.plugindata -> app.db.models",
@@ -4322,7 +4300,6 @@
"app.modules.imdb.api -> app.runtime.cache",
"app.modules.imdb.api -> app.runtime.log",
"app.modules.imdb.api -> app.runtime.settings",
"app.modules.imdb.api -> app.runtime.tasks",
"app.modules.indexer -> app.application",
"app.modules.indexer -> app.application.site",
"app.modules.indexer -> app.application.site.health",
@@ -6017,8 +5994,6 @@
"app.startup.context -> app.application.subscription.identity",
"app.startup.context -> app.application.subscription.mutation",
"app.startup.context -> app.application.workflow",
"app.startup.context -> app.runtime",
"app.startup.context -> app.runtime.tasks",
"app.startup.database -> app.adapters",
"app.startup.database -> app.adapters.system",
"app.startup.database -> app.adapters.system.backup",
@@ -6082,7 +6057,6 @@
"app.startup.lifecycle -> app.runtime.log",
"app.startup.lifecycle -> app.runtime.settings",
"app.startup.lifecycle -> app.runtime.state",
"app.startup.lifecycle -> app.runtime.tasks",
"app.startup.lifecycle -> app.runtime.topology",
"app.startup.lifecycle -> app.startup",
"app.startup.lifecycle -> app.startup.cache_initializer",
@@ -6197,7 +6171,6 @@
"app.startup.modules_initializer -> app.runtime.observability",
"app.startup.modules_initializer -> app.runtime.settings",
"app.startup.modules_initializer -> app.runtime.state",
"app.startup.modules_initializer -> app.runtime.tasks",
"app.startup.modules_initializer -> app.runtime.thread",
"app.startup.modules_initializer -> app.scheduler",
"app.startup.modules_initializer -> app.schemas",
@@ -6495,7 +6468,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 800,
"module_count": 799,
"modules": [
"app",
"app.adapters",
@@ -7188,7 +7161,6 @@
"app.runtime.scheduling",
"app.runtime.settings",
"app.runtime.state",
"app.runtime.tasks",
"app.runtime.thread",
"app.runtime.topology",
"app.scheduler",
@@ -0,0 +1,71 @@
"""插件管理器异步方法的执行边界回归。"""
from types import SimpleNamespace
from typing import Iterator
from unittest.mock import AsyncMock
import pytest
from app.foundation.singleton import Singleton
from app.sdk.plugins import PluginManager
@pytest.fixture
def plugin_manager() -> Iterator[PluginManager]:
"""构造隔离的插件管理器实例。"""
Singleton._instances.pop((PluginManager, (), frozenset()), None)
manager = PluginManager()
yield manager
Singleton._instances.pop((PluginManager, (), frozenset()), None)
@pytest.mark.asyncio
async def test_async_run_plugin_method_offloads_sync_plugin_method(
plugin_manager: PluginManager,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""异步入口不得在事件循环内直接执行同步插件方法。"""
calls: list[tuple[str, int]] = []
def sync_method(value: int) -> int:
calls.append(("sync", value))
return value + 1
plugin_manager.running_plugins["DemoPlugin"] = SimpleNamespace(
sync_method=sync_method,
)
worker = AsyncMock(return_value=2)
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.run_in_threadpool_to_completion",
worker,
)
assert await plugin_manager.async_run_plugin_method(
"DemoPlugin", "sync_method", 1
) == 2
worker.assert_awaited_once_with(sync_method, 1)
assert calls == []
@pytest.mark.asyncio
async def test_async_run_plugin_method_keeps_async_plugin_method_on_loop(
plugin_manager: PluginManager,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""原生协程插件方法继续直接等待,不额外占用同步 worker。"""
async def async_method(value: int) -> int:
return value + 1
plugin_manager.running_plugins["DemoPlugin"] = SimpleNamespace(
async_method=async_method,
)
worker = AsyncMock()
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.run_in_threadpool_to_completion",
worker,
)
assert await plugin_manager.async_run_plugin_method(
"DemoPlugin", "async_method", 1
) == 2
worker.assert_not_awaited()
+90
View File
@@ -0,0 +1,90 @@
"""运行时同步 worker 的取消与容量合同回归。"""
import asyncio
import threading
import pytest
from anyio.to_thread import current_default_thread_limiter
from app.runtime.execution import run_in_threadpool_to_completion
@pytest.mark.asyncio
async def test_threadpool_capacity_is_held_until_cancelled_call_finishes() -> None:
"""调用方取消后,执行令牌必须由真实同步调用持有到终态。"""
limiter = current_default_thread_limiter()
original_capacity = limiter.total_tokens
release = threading.Event()
first_started = threading.Event()
second_started = threading.Event()
def blocking_call(started: threading.Event) -> None:
started.set()
release.wait()
limiter.total_tokens = 1
first = asyncio.create_task(
run_in_threadpool_to_completion(blocking_call, first_started)
)
second = None
try:
while not first_started.is_set():
await asyncio.sleep(0)
first.cancel()
await asyncio.sleep(0)
first.cancel()
await asyncio.sleep(0)
assert first.done() is False
assert limiter.borrowed_tokens == 1
second = asyncio.create_task(
run_in_threadpool_to_completion(blocking_call, second_started)
)
await asyncio.sleep(0.01)
assert second_started.is_set() is False
release.set()
with pytest.raises(asyncio.CancelledError):
await first
await second
finally:
release.set()
if not first.done():
await asyncio.gather(first, return_exceptions=True)
if second is not None and not second.done():
await asyncio.gather(second, return_exceptions=True)
limiter.total_tokens = original_capacity
@pytest.mark.asyncio
async def test_cancelled_threadpool_call_preserves_worker_failure_as_cause() -> None:
"""调用方取消优先返回,线程终态异常仍保留为诊断原因。"""
loop = asyncio.get_running_loop()
previous_handler = loop.get_exception_handler()
loop_errors: list[dict] = []
release = threading.Event()
started = threading.Event()
def failing_call() -> None:
started.set()
release.wait()
raise ValueError("worker failed")
task = asyncio.create_task(run_in_threadpool_to_completion(failing_call))
while not started.is_set():
await asyncio.sleep(0)
task.cancel()
release.set()
loop.set_exception_handler(lambda _loop, context: loop_errors.append(context))
try:
with pytest.raises(asyncio.CancelledError) as error_info:
await task
await asyncio.sleep(0)
finally:
loop.set_exception_handler(previous_handler)
assert isinstance(error_info.value.__cause__, ValueError)
assert loop_errors == []