refactor: close plugin shutdown mutation races

This commit is contained in:
jxxghp
2026-08-23 21:55:22 +08:00
parent eb36e6be91
commit 820582ab12
16 changed files with 904 additions and 399 deletions
+87 -79
View File
@@ -22,6 +22,7 @@ from app.application.configuration import (
get_configured_system_config as SystemConfigOper,
get_runtime_settings,
)
from app.application.plugin.runtime import plugin_system_config_mutation
from app.runtime.log import logger
from app.schemas.event import ConfigChangeEventData
from app.schemas.types import EventType
@@ -259,94 +260,101 @@ class UpdateSystemSettingsTool(MoviePilotTool):
ensure_ascii=False,
)
current_value = self._load_setting_value(spec)
next_value = self._prepare_next_value(
spec=spec,
current_value=current_value,
value=value,
operation=operation,
remove_keys=remove_keys,
match_field=match_field,
match_value=match_value,
mutation_key = (
spec.systemconfig_key if spec.source == "systemconfig" else None
)
with plugin_system_config_mutation(mutation_key):
current_value = self._load_setting_value(spec)
next_value = self._prepare_next_value(
spec=spec,
current_value=current_value,
value=value,
operation=operation,
remove_keys=remove_keys,
match_field=match_field,
match_value=match_value,
)
event_value = next_value
changed = False
message = ""
if spec.source == "settings":
success, message = get_runtime_settings().update(spec.key, next_value)
if success is False:
return json.dumps(
{
"success": False,
"message": message or f"更新设置 {spec.key} 失败",
},
ensure_ascii=False,
event_value = next_value
changed = False
message = ""
if spec.source == "settings":
success, message = get_runtime_settings().update(
spec.key,
next_value,
)
changed = success is True
else:
normalized_value = self._normalize_systemconfig_value(next_value)
event_value = normalized_value
success = await self._get_system_config().async_set(
spec.systemconfig_key,
normalized_value,
)
changed = success is True
if success is False:
return json.dumps(
{
"success": False,
"message": message or f"更新设置 {spec.key} 失败",
},
ensure_ascii=False,
)
changed = success is True
else:
normalized_value = self._normalize_systemconfig_value(next_value)
event_value = normalized_value
success = await self._get_system_config().async_set(
spec.systemconfig_key,
normalized_value,
)
changed = success is True
if changed:
await eventmanager.async_send_event(
etype=EventType.ConfigChanged,
data=ConfigChangeEventData(
key=spec.key,
value=event_value,
change_type="update",
),
)
if changed:
await eventmanager.async_send_event(
etype=EventType.ConfigChanged,
data=ConfigChangeEventData(
key=spec.key,
value=event_value,
change_type="update",
),
)
saved_value = self._load_setting_value(spec)
redact_values = (
should_redact_setting(spec, saved_value)
or should_redact_setting(spec, current_value)
)
response_previous_value = (
redact_secret_value(
current_value,
redact_scalar=is_secret_setting_key(spec.key),
saved_value = self._load_setting_value(spec)
redact_values = (
should_redact_setting(spec, saved_value)
or should_redact_setting(spec, current_value)
)
if redact_values
else current_value
)
response_saved_value = (
redact_secret_value(
saved_value,
redact_scalar=is_secret_setting_key(spec.key),
response_previous_value = (
redact_secret_value(
current_value,
redact_scalar=is_secret_setting_key(spec.key),
)
if redact_values
else current_value
)
if redact_values
else saved_value
)
if not changed and not message:
message = "配置值未发生变化"
response_saved_value = (
redact_secret_value(
saved_value,
redact_scalar=is_secret_setting_key(spec.key),
)
if redact_values
else saved_value
)
if not changed and not message:
message = "配置值未发生变化"
return json.dumps(
{
"success": True,
"message": message or f"系统设置 {spec.key} 已更新",
"changed": changed,
"operation": operation,
"setting": {
"setting_key": spec.key,
"source": spec.source,
"group": spec.group,
"label": spec.label,
return json.dumps(
{
"success": True,
"message": message or f"系统设置 {spec.key} 已更新",
"changed": changed,
"operation": operation,
"setting": {
"setting_key": spec.key,
"source": spec.source,
"group": spec.group,
"label": spec.label,
},
"values_redacted": redact_values,
"previous_value": response_previous_value,
"saved_value": response_saved_value,
},
"values_redacted": redact_values,
"previous_value": response_previous_value,
"saved_value": response_saved_value,
},
ensure_ascii=False,
indent=2,
default=str,
)
ensure_ascii=False,
indent=2,
default=str,
)
except Exception as e:
logger.error(f"更新系统设置失败: {e}", exc_info=True)
return json.dumps(
+18 -8
View File
@@ -45,6 +45,7 @@ from app.application.configuration import (
get_configured_system_config,
get_runtime_settings,
)
from app.application.plugin.runtime import plugin_system_config_mutation
from app.api.dependencies.auth import (
get_current_active_superuser,
get_current_active_superuser_async,
@@ -67,6 +68,7 @@ from app.runtime.state import SystemHelper
from app.runtime.log import logger
from app.application.scheduling import Scheduler
from app.schemas.event import ConfigChangeEventData
from app.schemas.exception import PluginMutationRejectedError
from app.schemas.types import SystemConfigKey, EventType
from app.foundation.crypto import HashUtils
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
@@ -1070,14 +1072,22 @@ async def set_setting(
if isinstance(value, list):
value = list(filter(None, value))
value = value if value else None
success = await get_configured_system_config().async_set(key, value)
if success:
# 发送配置变更事件
await eventmanager.async_send_event(
etype=EventType.ConfigChanged,
data=ConfigChangeEventData(key=key, value=value, change_type="update"),
)
return _SchemaResponse(success=True)
try:
with plugin_system_config_mutation(key):
success = await get_configured_system_config().async_set(key, value)
if success:
# 发送配置变更事件
await eventmanager.async_send_event(
etype=EventType.ConfigChanged,
data=ConfigChangeEventData(
key=key,
value=value,
change_type="update",
),
)
return _SchemaResponse(success=True)
except PluginMutationRejectedError as error:
return _SchemaResponse(success=False, message=str(error))
else:
return _SchemaResponse(success=False, message=f"配置项 '{key}' 不存在")
+13 -9
View File
@@ -30,6 +30,16 @@ PluginMutationAdmission = Callable[[str], ContextManager[None]]
PluginPackageWriteGuard = Callable[[str], ContextManager[None]]
async def _await_task_to_terminal(task: asyncio.Task[Any]) -> Any:
"""忽略调用方的重复取消,直到受保护子任务进入真实终态。"""
while not task.done():
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
continue
return task.result()
@dataclass(frozen=True, slots=True)
class PluginInstallRollback:
"""描述失败安装中各类可补偿副作用的恢复结果。"""
@@ -183,7 +193,7 @@ class PluginInstallCommand:
state.checkpoint = checkpoint
except asyncio.CancelledError:
try:
state.checkpoint = await asyncio.shield(checkpoint_task)
state.checkpoint = await _await_task_to_terminal(checkpoint_task)
except BaseException:
pass
raise
@@ -359,14 +369,8 @@ class PluginInstallCommand:
)
)
try:
result = await asyncio.shield(rollback_task)
except asyncio.CancelledError:
try:
result = await asyncio.shield(rollback_task)
except BaseException as err:
logger.error(f"插件 {plugin_id} 取消后的补偿未完成:{err}")
return
except Exception as err:
result = await _await_task_to_terminal(rollback_task)
except BaseException as err:
logger.error(f"插件 {plugin_id} 取消后的补偿失败:{err}")
return
if result.rollback.errors:
+28
View File
@@ -3,8 +3,21 @@
from __future__ import annotations
from collections.abc import Callable
from contextlib import nullcontext
from typing import Any, ContextManager, Protocol
from app.schemas.types import SystemConfigKey
PLUGIN_MUTATION_SYSTEM_CONFIG_KEYS = frozenset(
{
SystemConfigKey.UserInstalledPlugins,
SystemConfigKey.PluginInstances,
SystemConfigKey.PluginFolders,
}
)
class PluginRuntime(Protocol):
"""声明入口层消费的插件宿主能力。"""
@@ -36,3 +49,18 @@ def configure_plugin_runtime(provider: PluginRuntimeProvider) -> None:
def get_plugin_manager() -> PluginRuntime:
"""返回当前组合根提供的插件运行时能力。"""
return _runtime_provider()
def plugin_system_config_mutation(
key: str | SystemConfigKey | None,
) -> ContextManager[None]:
"""仅为会改变插件运行态所有权的系统配置取得 mutation lease。"""
if key is None:
return nullcontext()
try:
normalized_key = key if isinstance(key, SystemConfigKey) else SystemConfigKey(key)
except ValueError:
return nullcontext()
if normalized_key not in PLUGIN_MUTATION_SYSTEM_CONFIG_KEYS:
return nullcontext()
return get_plugin_manager().mutation(f"更新插件系统配置 {normalized_key.value}")
+17 -17
View File
@@ -48,34 +48,34 @@ class PluginMutationAdmission:
def is_held(self) -> bool:
"""判断当前执行上下文是否持有仍有效的本 admission lease。"""
context = self._current_context.get()
return bool(
context
and context.admission is self
and context.open
and context.holders > 0
)
with self._condition:
return bool(
context
and context.admission is self
and context.open
and context.holders > 0
)
@contextmanager
def hold(self, operation: str) -> Iterator[None]:
"""取得可变事务 lease;封口后仅允许已获准事务的嵌套调用。"""
context = self._current_context.get()
nested = bool(
context
and context.admission is self
and context.open
and context.holders > 0
)
context_token = None
if not nested:
context = _MutationContext(admission=self)
context_token = self._current_context.set(context)
assert context is not None
acquired = False
try:
with self._condition:
nested = bool(
context
and context.admission is self
and context.open
and context.holders > 0
)
if not self._accepting and not nested:
raise PluginMutationRejectedError(operation)
if not nested:
context = _MutationContext(admission=self)
context_token = self._current_context.set(context)
assert context is not None
self._active_count += 1
context.holders += 1
acquired = True
+1
View File
@@ -270,6 +270,7 @@ SCHEMA_EXPORTS = {
'PluginMarketSyncData': ('app.schemas.system', 'PluginMarketSyncData'),
'PluginMarketSyncRequest': ('app.schemas.system', 'PluginMarketSyncRequest'),
'PluginMemoryInfo': ('app.schemas.plugin', 'PluginMemoryInfo'),
'PluginMutationRejectedError': ('app.schemas.exception', 'PluginMutationRejectedError'),
'PluginRating': ('app.schemas.plugin', 'PluginRating'),
'PluginRatingMap': ('app.schemas.plugin', 'PluginRatingMap'),
'PluginRatingRequest': ('app.schemas.plugin', 'PluginRatingRequest'),