From 19e062c17884efe72e34e9f336cf80d83ee6cfc5 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:12:02 +0800 Subject: [PATCH] feat(plugin): attribute free-threaded GIL fallback to plugins (#6767) Co-Authored-By: Claude Opus 5.5 --- app/api/endpoints/plugin.py | 5 + app/runtime/extensions/plugin/gil.py | 33 ++++ app/runtime/extensions/plugin/lifecycle.py | 13 +- app/runtime/extensions/plugin/loader.py | 17 +- app/runtime/extensions/plugin/manager.py | 19 ++- app/runtime/extensions/plugin/registry.py | 23 ++- app/runtime/extensions/plugin/runtime.py | 2 + app/schemas/plugin.py | 4 + docs/architecture-overview.md | 4 +- docs/refactor/optimization-checklist.md | 2 +- .../architecture/dependency-baseline.json | 14 +- .../startup-performance-baseline.json | 6 +- tests/test_plugin_endpoint.py | 2 + tests/test_plugin_gil_attribution.py | 158 ++++++++++++++++++ tests/test_plugin_mutation_admission.py | 1 + 15 files changed, 287 insertions(+), 16 deletions(-) create mode 100644 app/runtime/extensions/plugin/gil.py create mode 100644 tests/test_plugin_gil_attribution.py diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 3c51f0041..90fb7b5be 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -259,6 +259,11 @@ async def runtime_status( pending_count=sum(status in pending for status in statuses.values()), failed_count=sum(status in failed for status in statuses.values()), restart_required_plugin_ids=sorted(restart_requirements), + gil_enabled_plugin_ids=[ + plugin_id + for plugin_id in plugin_manager.get_plugin_gil_fallbacks() + if plugin_id.lower() in installed_plugin_ids + ], ) diff --git a/app/runtime/extensions/plugin/gil.py b/app/runtime/extensions/plugin/gil.py new file mode 100644 index 000000000..c21a3ddea --- /dev/null +++ b/app/runtime/extensions/plugin/gil.py @@ -0,0 +1,33 @@ +"""free-threaded 运行时下插件触发 GIL 回退的归因。""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from contextlib import contextmanager + +from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled + +# 按插件 ID 记录"该插件的加载使进程回退到 GIL"的端口,由注册表实现 +GilFallbackRecorder = Callable[[str], None] + + +def ignore_gil_fallback(_plugin_id: str) -> None: + """默认归因端口:未装配注册表的加载器/生命周期不记录归因。""" + + +@contextmanager +def attribute_gil_fallback(plugin_id: str, recorder: GilFallbackRecorder) -> Iterator[None]: + """ + 观察包裹区间内 GIL 是否从关闭变为开启,发生时把回退归因到该插件。 + + GIL 一旦被原生扩展开启就会保持到进程结束,因此只有第一个触发转换的插件能被观察到; + 之后加载的插件即使同样缺少 free-threaded 声明,也无法再被区分。 + 插件在加载之后才惰性导入的原生扩展不在任何包裹区间内,同样无法归因。 + 区间内抛出的异常照常向外传播,归因在 finally 中完成,加载失败的插件同样会被记录。 + """ + gil_enabled_before = is_gil_enabled() + try: + yield + finally: + if is_free_threaded_runtime() and not gil_enabled_before and is_gil_enabled(): + recorder(plugin_id) diff --git a/app/runtime/extensions/plugin/lifecycle.py b/app/runtime/extensions/plugin/lifecycle.py index 8c6e6fb64..46382e257 100644 --- a/app/runtime/extensions/plugin/lifecycle.py +++ b/app/runtime/extensions/plugin/lifecycle.py @@ -11,6 +11,11 @@ from pathlib import Path from typing import Any, Optional, ParamSpec, TypeVar, cast from app.runtime.extensions.plugin.database import PluginDatabase +from app.runtime.extensions.plugin.gil import ( + GilFallbackRecorder, + attribute_gil_fallback, + ignore_gil_fallback, +) from app.runtime.log import bind_plugin_instance from app.runtime.observability import record_metric from app.schemas.plugin import PluginRuntimeStatus @@ -77,6 +82,7 @@ class PluginLifecycle: event_sender: Callable[..., Any], refresh_classification: Callable[[str, Any], None] | None = None, remove_classification: Callable[[str], None] | None = None, + gil_fallback_recorder: GilFallbackRecorder = ignore_gil_fallback, ) -> None: """保存注册表、加载器、数据库和事件端口。""" self._classes = classes @@ -100,6 +106,7 @@ class PluginLifecycle: self._remove_classification = remove_classification or ( lambda _plugin_id: None ) + self._gil_fallback_recorder = gil_fallback_recorder self._lifecycle_lock = threading.RLock() self._quiesced_hooks: dict[str, set[str]] = {} @@ -142,7 +149,11 @@ class PluginLifecycle: continue self._remove_classification(current_id) self._classes[current_id] = plugin - with bind_plugin_instance(current_id): + # 插件在 init_plugin 里才导入原生扩展时,回退发生在这里 + with bind_plugin_instance(current_id), attribute_gil_fallback( + current_id, + self._gil_fallback_recorder, + ): instance = plugin() instance.init_plugin(self._plugin_config(current_id)) self._ensure_database(current_id, instance) diff --git a/app/runtime/extensions/plugin/loader.py b/app/runtime/extensions/plugin/loader.py index fcbdaee5d..e3c7e67f2 100644 --- a/app/runtime/extensions/plugin/loader.py +++ b/app/runtime/extensions/plugin/loader.py @@ -13,6 +13,11 @@ from typing import Any, Optional from app.domain.plugin import check_plugin_runtime_compatibility from app.foundation.environment import is_free_threaded_runtime +from app.runtime.extensions.plugin.gil import ( + GilFallbackRecorder, + attribute_gil_fallback, + ignore_gil_fallback, +) from app.runtime.settings import get_runtime_setting from app.schemas.plugin import PluginInstance, PluginRuntimeStatus @@ -46,13 +51,15 @@ class PluginLoader: log: Any, runtime_status_writer: Optional[PluginRuntimeStatusWriter] = None, runtime_declaration: PluginRuntimeDeclarationReader = _undeclared_runtime, + gil_fallback_recorder: GilFallbackRecorder = ignore_gil_fallback, ) -> None: - """保存插件目录、导入前置能力、状态回写端口、声明读取端口和日志端口。""" + """保存插件目录、导入前置能力、状态回写端口、声明读取端口、GIL 归因端口和日志端口。""" self._plugins_root = plugins_root self._import_preparer = import_preparer self._import_scanner = import_scanner self._runtime_status_writer = runtime_status_writer self._runtime_declaration = runtime_declaration + self._gil_fallback_recorder = gil_fallback_recorder self._logger = log def load( @@ -116,7 +123,13 @@ class PluginLoader: plugin_id=plugin_dir.name, plugin_dir=plugin_dir, ) - module = importlib.import_module(module_name) + # 插件模块顶层导入原生扩展最常见,GIL 回退多发生在这一步; + # 全量启动时逐个模块观察,才能把回退归因到具体插件 + with attribute_gil_fallback( + installed_ids.get(plugin_dir.name, plugin_dir.name), + self._gil_fallback_recorder, + ): + module = importlib.import_module(module_name) for name, candidate in module.__dict__.items(): if name.startswith("_") or not isinstance(candidate, type): continue diff --git a/app/runtime/extensions/plugin/manager.py b/app/runtime/extensions/plugin/manager.py index 6814fc201..515aee752 100644 --- a/app/runtime/extensions/plugin/manager.py +++ b/app/runtime/extensions/plugin/manager.py @@ -35,6 +35,7 @@ from app.runtime.extensions.plugin.dependency import ( from app.runtime.extensions.plugin.metadata import PluginMetadataMapper from app.runtime.extensions.plugin.monitor import PluginChangeMonitor from app.runtime.extensions.plugin.projection import PluginProjection +from app.runtime.extensions.plugin.registry import PluginRegistry from app.runtime.extensions.plugin.runtime import PluginRuntime from app.runtime.extensions.plugin.tools import PluginToolCatalog from app.runtime.log import clear_plugin_instance_log_level as clear_instance_log_level_override @@ -86,17 +87,25 @@ def _warn_if_plugin_enabled_gil( *, gil_enabled_before: bool, plugin_id: Optional[str], + registry: PluginRegistry, ) -> None: - """记录插件加载使 free-threaded 进程重新启用 GIL 的真实转换。""" + """ + 记录插件加载使 free-threaded 进程重新启用 GIL 的真实转换。 + + 加载器与生命周期已按单个插件归因;这里是整次加载的兜底:定向加载时把转换补记到该插件 + (覆盖事件注册等未被逐插件包裹的阶段),全量加载时用已归因的插件替代笼统的"集合"。 + """ if ( not is_free_threaded_runtime() or gil_enabled_before or not is_gil_enabled() ): return + if plugin_id: + registry.mark_gil_fallback(plugin_id) logger.warning( "加载插件%s后 free-threaded 运行时已启用 GIL,请检查原生扩展兼容性", - plugin_id or "集合", + plugin_id or "、".join(registry.gil_fallback_snapshot()) or "集合", ) @@ -282,6 +291,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): _warn_if_plugin_enabled_gil( gil_enabled_before=gil_enabled_before, plugin_id=pid, + registry=self._plugin_registry, ) except PluginMutationRejectedError as error: logger.warning(str(error)) @@ -641,6 +651,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): _warn_if_plugin_enabled_gil( gil_enabled_before=gil_enabled_before, plugin_id=plugin_id, + registry=self._plugin_registry, ) except PluginMutationRejectedError as error: logger.warning(str(error)) @@ -747,6 +758,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """返回当前进程的插件原生依赖重启要求。""" return self._plugin_registry.restart_required_snapshot() + def get_plugin_gil_fallbacks(self) -> list[str]: + """返回当前进程内加载时导致 free-threaded 运行时回退到 GIL 的插件 ID。""" + return self._plugin_registry.gil_fallback_snapshot() + def is_plugin_settling(self) -> bool: """返回插件源码和依赖是否仍在后台恢复。""" return self._plugin_registry.settling diff --git a/app/runtime/extensions/plugin/registry.py b/app/runtime/extensions/plugin/registry.py index 56e21adbf..9ba04e63d 100644 --- a/app/runtime/extensions/plugin/registry.py +++ b/app/runtime/extensions/plugin/registry.py @@ -14,6 +14,8 @@ class PluginRegistry: self._running: Dict[str, Any] = {} self._runtime_statuses: Dict[str, PluginRuntimeStatus] = {} self._restart_required_plugins: Dict[str, tuple[str, ...]] = {} + # 加载时使 free-threaded 进程回退到 GIL 的插件,只在当前进程内有效,重启后重新观察 + self._gil_fallback_plugins: Dict[str, bool] = {} self._settling = False self._generation = 0 @@ -92,6 +94,17 @@ class PluginRegistry: """返回重启后才能完整激活的插件及原生发行包。""" return dict(self._restart_required_plugins) + def mark_gil_fallback(self, plugin_id: str) -> None: + """记录插件加载使 free-threaded 运行时回退到 GIL。""" + if plugin_id in self._gil_fallback_plugins: + return + self._gil_fallback_plugins[plugin_id] = True + self._generation += 1 + + def gil_fallback_snapshot(self) -> list[str]: + """返回当前进程内导致 GIL 回退的插件 ID,保持记录顺序。""" + return list(self._gil_fallback_plugins) + def set_settling(self, settling: bool) -> None: """标记启动后的插件源码与依赖收敛任务是否仍在执行。""" if self._settling == settling: @@ -117,7 +130,10 @@ class PluginRegistry: restart_requirement_removed = ( self._restart_required_plugins.pop(plugin_id, None) is not None ) - if status_removed or restart_requirement_removed: + gil_fallback_removed = ( + self._gil_fallback_plugins.pop(plugin_id, None) is not None + ) + if status_removed or restart_requirement_removed or gil_fallback_removed: self._generation += 1 def clear(self) -> None: @@ -125,9 +141,12 @@ class PluginRegistry: self._classes.clear() self._running.clear() had_runtime_state = bool( - self._runtime_statuses or self._restart_required_plugins + self._runtime_statuses + or self._restart_required_plugins + or self._gil_fallback_plugins ) self._runtime_statuses.clear() self._restart_required_plugins.clear() + self._gil_fallback_plugins.clear() if had_runtime_state: self._generation += 1 diff --git a/app/runtime/extensions/plugin/runtime.py b/app/runtime/extensions/plugin/runtime.py index e55f0e577..910d3bef1 100644 --- a/app/runtime/extensions/plugin/runtime.py +++ b/app/runtime/extensions/plugin/runtime.py @@ -171,6 +171,7 @@ def build_plugin_runtime( # 否则启动期全量加载会把它们跳过得无声无息 runtime_status_writer=registry.set_runtime_status, runtime_declaration=environment.runtime_declaration, + gil_fallback_recorder=registry.mark_gil_fallback, log=environment.logger, ) tools = PluginToolCatalog(max_attempts=tool_build_max_attempts) @@ -242,6 +243,7 @@ def build_plugin_runtime( event_sender=eventmanager.send_event, refresh_classification=refresh_classification, remove_classification=classification.remove, + gil_fallback_recorder=registry.mark_gil_fallback, ) metadata = PluginMetadataMapper( plugin_instance=registry.instance, diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index bd1c37111..9d57a2934 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -259,6 +259,10 @@ class PluginRuntimeSummary(BaseModel): default_factory=list, description="重启后才能完整激活新原生依赖的物理插件 ID", ) + gil_enabled_plugin_ids: List[str] = Field( + default_factory=list, + description="当前进程内加载时导致 free-threaded 运行时回退到 GIL 的插件 ID", + ) class PluginRuntimeCommandCapability(BaseModel): # type: ignore[misc] diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index ebb2cdc1f..07731b751 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -761,7 +761,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 1044 | -| 内部导入边 | 8,874 | +| 内部导入边 | 8,881 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | @@ -771,7 +771,7 @@ flowchart LR | Model/Oper 自动事务与自建 Session | 0 | | 组合根外 `SystemConfigOper()` | 0 | -整理失败反馈由 `app.application.transfer.feedback` 集中投影;Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。当前 `app.startup.lifecycle` 为 553、`app.factory` 为 565、`app.main` 为 567。性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 +整理失败反馈由 `app.application.transfer.feedback` 集中投影;Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。当前 `app.startup.lifecycle` 为 566、`app.factory` 为 578、`app.main` 为 580。性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event 语义门禁,`Check host architecture snapshot` 再执行快照测试及一次 diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index c27f1c8e8..2bd9153b0 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1044 / 8,874 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档,分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 | +| 宿主 Python 模块 / 内部依赖边 | 1045 / 8,881 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档,分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index e09ede90e..b2da95d2b 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1084,8 +1084,8 @@ "runtime_only": true } }, - "edge_count": 8874, - "edge_sha256": "d7424a9ce34ac1c30dab0449e660632db539aaa66c1023ac68fe04f979b4efdd", + "edge_count": 8881, + "edge_sha256": "700858f0eb08a886c206c9be9685964b01f0e3412bed7b1c1fa84fa343231a14", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -8478,10 +8478,13 @@ "app.runtime.extensions.plugin.dependency -> app.runtime.extensions.plugin.system", "app.runtime.extensions.plugin.dependency -> app.schemas", "app.runtime.extensions.plugin.dependency -> app.schemas.plugin", + "app.runtime.extensions.plugin.gil -> app.foundation", + "app.runtime.extensions.plugin.gil -> app.foundation.environment", "app.runtime.extensions.plugin.lifecycle -> app.runtime", "app.runtime.extensions.plugin.lifecycle -> app.runtime.extensions", "app.runtime.extensions.plugin.lifecycle -> app.runtime.extensions.plugin", "app.runtime.extensions.plugin.lifecycle -> app.runtime.extensions.plugin.database", + "app.runtime.extensions.plugin.lifecycle -> app.runtime.extensions.plugin.gil", "app.runtime.extensions.plugin.lifecycle -> app.runtime.log", "app.runtime.extensions.plugin.lifecycle -> app.runtime.observability", "app.runtime.extensions.plugin.lifecycle -> app.schemas", @@ -8491,6 +8494,9 @@ "app.runtime.extensions.plugin.loader -> app.foundation", "app.runtime.extensions.plugin.loader -> app.foundation.environment", "app.runtime.extensions.plugin.loader -> app.runtime", + "app.runtime.extensions.plugin.loader -> app.runtime.extensions", + "app.runtime.extensions.plugin.loader -> app.runtime.extensions.plugin", + "app.runtime.extensions.plugin.loader -> app.runtime.extensions.plugin.gil", "app.runtime.extensions.plugin.loader -> app.runtime.settings", "app.runtime.extensions.plugin.loader -> app.schemas", "app.runtime.extensions.plugin.loader -> app.schemas.plugin", @@ -8512,6 +8518,7 @@ "app.runtime.extensions.plugin.manager -> app.runtime.extensions.plugin.metadata", "app.runtime.extensions.plugin.manager -> app.runtime.extensions.plugin.monitor", "app.runtime.extensions.plugin.manager -> app.runtime.extensions.plugin.projection", + "app.runtime.extensions.plugin.manager -> app.runtime.extensions.plugin.registry", "app.runtime.extensions.plugin.manager -> app.runtime.extensions.plugin.runtime", "app.runtime.extensions.plugin.manager -> app.runtime.extensions.plugin.tools", "app.runtime.extensions.plugin.manager -> app.runtime.log", @@ -9962,7 +9969,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 1044, + "module_count": 1045, "modules": [ "app", "app.adapters", @@ -10827,6 +10834,7 @@ "app.runtime.extensions.plugin.database", "app.runtime.extensions.plugin.datadir", "app.runtime.extensions.plugin.dependency", + "app.runtime.extensions.plugin.gil", "app.runtime.extensions.plugin.lifecycle", "app.runtime.extensions.plugin.loader", "app.runtime.extensions.plugin.loglevel", diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index 1184310bd..c3d7bbef8 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -6,7 +6,7 @@ "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_app_module_count": 565, + "loaded_app_module_count": 566, "max_ms": 1293.338, "median_ms": 1156.239, "min_ms": 1102.806, @@ -17,7 +17,7 @@ ] }, "app.factory": { - "loaded_app_module_count": 577, + "loaded_app_module_count": 578, "max_ms": 1127.911, "median_ms": 1122.382, "min_ms": 1119.221, @@ -28,7 +28,7 @@ ] }, "app.main": { - "loaded_app_module_count": 579, + "loaded_app_module_count": 580, "max_ms": 1188.652, "median_ms": 1183.509, "min_ms": 1174.522, diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index fe28df739..c1680721c 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -535,6 +535,7 @@ def test_runtime_status_reports_pending_and_terminal_counts(): "NativePlugin": ("native-demo",), "RemovedPlugin": ("native-removed",), } + plugin_manager.get_plugin_gil_fallbacks.return_value = ["NativePlugin", "RemovedPlugin"] config = MagicMock() config.get.return_value = ["NativePlugin"] @@ -552,6 +553,7 @@ def test_runtime_status_reports_pending_and_terminal_counts(): assert result.pending_count == 2 assert result.failed_count == 1 assert result.restart_required_plugin_ids == ["NativePlugin"] + assert result.gil_enabled_plugin_ids == ["NativePlugin"] def test_reload_endpoint_reports_load_failure(monkeypatch): diff --git a/tests/test_plugin_gil_attribution.py b/tests/test_plugin_gil_attribution.py new file mode 100644 index 000000000..f3c722322 --- /dev/null +++ b/tests/test_plugin_gil_attribution.py @@ -0,0 +1,158 @@ +"""free-threaded 运行时插件触发 GIL 回退的归因测试。""" + +from __future__ import annotations + +from types import ModuleType, SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from app.runtime.extensions.plugin import gil as gil_module +from app.runtime.extensions.plugin import loader as loader_module +from app.runtime.extensions.plugin.database import PluginDatabase +from app.runtime.extensions.plugin.gil import attribute_gil_fallback +from app.runtime.extensions.plugin.lifecycle import PluginLifecycle +from app.runtime.extensions.plugin.loader import PluginLoader +from app.runtime.extensions.plugin.registry import PluginRegistry +from app.schemas.plugin import PluginRuntimeStatus + + +class _GilState: + """模拟进程级 GIL 状态:一旦开启就保持到进程结束。""" + + def __init__(self) -> None: + self.enabled = False + + def enable(self) -> None: + self.enabled = True + + +@pytest.fixture +def gil_state(monkeypatch) -> _GilState: + """在 free-threaded 运行时下用可控的 GIL 状态替换真实解释器状态。""" + state = _GilState() + monkeypatch.setattr(gil_module, "is_free_threaded_runtime", lambda: True) + monkeypatch.setattr(gil_module, "is_gil_enabled", lambda: state.enabled) + return state + + +def test_attribution_records_only_transition(gil_state): + """只有区间内发生关闭到开启的转换才归因,GIL 已开启时后续插件不再被记录。""" + recorded: list[str] = [] + + with attribute_gil_fallback("Quiet", recorded.append): + pass + with attribute_gil_fallback("Native", recorded.append): + gil_state.enable() + with attribute_gil_fallback("Later", recorded.append): + pass + + assert recorded == ["Native"] + + +def test_attribution_records_even_when_load_fails(gil_state): + """导入原生扩展后插件自身抛错,回退事实仍要归因,异常照常外抛。""" + recorded: list[str] = [] + + with pytest.raises(RuntimeError): + with attribute_gil_fallback("Broken", recorded.append): + gil_state.enable() + raise RuntimeError("init failed") + + assert recorded == ["Broken"] + + +def test_standard_runtime_never_attributes(monkeypatch): + """标准解释器 GIL 本来就开启,不存在回退,不做归因。""" + monkeypatch.setattr(gil_module, "is_free_threaded_runtime", lambda: False) + states = iter((False, True)) + monkeypatch.setattr(gil_module, "is_gil_enabled", lambda: next(states)) + recorded: list[str] = [] + + with attribute_gil_fallback("Demo", recorded.append): + pass + + assert recorded == [] + + +def test_registry_tracks_gil_fallback_with_generation(): + """注册表只在首次记录时推进刷新代次,移除与清空会同步清掉归因。""" + registry = PluginRegistry() + start = registry.generation + + registry.mark_gil_fallback("Native") + registry.mark_gil_fallback("Native") + assert registry.gil_fallback_snapshot() == ["Native"] + assert registry.generation == start + 1 + + registry.remove("Native") + assert registry.gil_fallback_snapshot() == [] + assert registry.generation == start + 2 + + registry.mark_gil_fallback("Other") + registry.clear() + assert registry.gil_fallback_snapshot() == [] + + +def test_loader_attributes_module_import_to_installed_plugin_id(tmp_path, monkeypatch, gil_state): + """全量加载时逐个模块观察,回退归因到导入原生扩展的插件,并还原安装时的 ID 大小写。""" + for name in ("pureplugin", "nativeplugin"): + (tmp_path / name).mkdir() + (tmp_path / name / "__init__.py").write_text("", encoding="utf-8") + + def import_module(module_name: str) -> ModuleType: + if module_name == "app.plugins.nativeplugin": + gil_state.enable() + return ModuleType(module_name) + + monkeypatch.setattr(loader_module, "importlib", SimpleNamespace(import_module=import_module)) + recorded: list[str] = [] + loader = PluginLoader( + plugins_root=tmp_path, + import_preparer=lambda **_kwargs: None, + import_scanner=lambda **_kwargs: None, + log=MagicMock(), + gil_fallback_recorder=recorded.append, + ) + + loader.load(None, ["PurePlugin", "NativePlugin"], lambda _candidate: True) + + assert recorded == ["NativePlugin"] + + +def test_lifecycle_attributes_init_plugin_import(gil_state): + """插件在 init_plugin 中才导入原生扩展时,回退归因到该插件。""" + + class LazyNative: + plugin_name = "惰性原生" + plugin_version = "1.0.0" + + def init_plugin(self, _config): + gil_state.enable() + + @staticmethod + def get_state(): + return True + + recorded: list[str] = [] + lifecycle = PluginLifecycle( + classes={}, + running={}, + load_plugins=lambda _plugin_id, _installed, _check: [LazyNative], + loadable_plugins=lambda: ["LazyNative"], + plugin_config=lambda _plugin_id: {}, + auth_checker=lambda _plugin: True, + clear_modules=MagicMock(), + clear_tools=MagicMock(), + enable_events=MagicMock(), + disable_events=MagicMock(), + runtime_status_writer=lambda _plugin_id, _status: None, + runtime_compatible=lambda _plugin_id: True, + database=lambda: PluginDatabase(), + log=MagicMock(), + event_sender=MagicMock(), + gil_fallback_recorder=recorded.append, + ) + + assert lifecycle.start() == {"LazyNative": PluginRuntimeStatus.ACTIVE} + assert recorded == ["LazyNative"] diff --git a/tests/test_plugin_mutation_admission.py b/tests/test_plugin_mutation_admission.py index 4252f5ab2..5a4250294 100644 --- a/tests/test_plugin_mutation_admission.py +++ b/tests/test_plugin_mutation_admission.py @@ -216,6 +216,7 @@ def test_reload_attributes_gil_transition_to_plugin( warning.assert_called_once() assert warning.call_args.args[1] == "DemoPlugin" + assert plugin_manager.get_plugin_gil_fallbacks() == ["DemoPlugin"] @pytest.mark.asyncio