mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
refactor: close plugin shutdown mutation races
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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}' 不存在")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user