Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions astrbot/core/star/star_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -1549,6 +1549,11 @@ async def uninstall_plugin(

await self._unbind_plugin(plugin_name, plugin.module_path)

plugin_module_prefix = plugin.module_path.rsplit(".", 1)[0]
plugin_dir = os.path.join(ppath, root_dir_name)
await self._cancel_plugin_tasks(plugin_module_prefix)
self._release_plugin_font_handles(plugin, plugin_dir)

# 删除插件文件夹
try:
remove_dir(os.path.join(ppath, root_dir_name))
Expand All @@ -1564,6 +1569,73 @@ async def uninstall_plugin(
delete_data=delete_data,
)

async def _cancel_plugin_tasks(self, plugin_module_prefix: str) -> None:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider replacing the global task and GC introspection logic with explicit per-plugin registries for tasks and fonts to simplify uninstall handling.

The main complexity comes from the global introspection in _cancel_plugin_tasks and _release_plugin_font_handles. Both can be simplified by explicit per‑plugin tracking while keeping the same behavior.

1. Replace _cancel_plugin_tasks introspection with explicit tracking

Instead of scanning asyncio.all_tasks() and inspecting frames, track tasks when they are created. For example, add a per‑plugin registry and a helper:

# somewhere on the manager
self._plugin_tasks: dict[str, set[asyncio.Task]] = defaultdict(set)

def create_plugin_task(self, plugin_name: str, coro: Coroutine) -> asyncio.Task:
    task = asyncio.create_task(coro)
    self._plugin_tasks[plugin_name].add(task)
    task.add_done_callback(lambda t, name=plugin_name: self._plugin_tasks[name].discard(t))
    return task

Then _cancel_plugin_tasks becomes much simpler and explicit:

async def _cancel_plugin_tasks(self, plugin_name: str) -> None:
    tasks = list(self._plugin_tasks.get(plugin_name, ()))
    if not tasks:
        return

    for t in tasks:
        if not t.done():
            t.cancel()

    try:
        await asyncio.wait_for(
            asyncio.gather(*tasks, return_exceptions=True),
            timeout=5.0,
        )
    except asyncio.TimeoutError:
        logger.warning(
            f"插件 {plugin_name} 的部分 tasks 在 5 秒内未能取消,可能导致文件句柄未释放。"
        )

    self._plugin_tasks.pop(plugin_name, None)

Callers inside plugin code (or the plugin manager) should use create_plugin_task instead of asyncio.create_task, preserving all existing behavior while avoiding frame inspection and global task scanning.

2. Replace GC sweeping for fonts with an explicit font registry

Instead of walking gc.get_objects() / get_referrers, ensure fonts loaded by a plugin are tracked when created. For example, centralize font loading for plugins:

# on the plugin object or manager
class Plugin:
    def __init__(self, ...):
        self.fonts: list[ImageFont.FreeTypeFont] = []

def load_plugin_font(self, plugin: Plugin, path: str, *args, **kwargs) -> ImageFont.FreeTypeFont:
    font = ImageFont.truetype(path, *args, **kwargs)
    plugin.fonts.append(font)
    return font

Then _release_plugin_font_handles can directly release/clear them:

def _release_plugin_font_handles(self, plugin, plugin_dir: str) -> None:
    # clear plugin references first
    plugin.star_cls = None
    plugin.module = None
    plugin.star_cls_type = None

    for font in getattr(plugin, "fonts", []):
        try:
            font.close()  # if available in your Pillow version
        except Exception:
            pass
    plugin.fonts.clear()

If you can’t modify plugin code directly, you can still centralize font creation in the manager (e.g. exposing a get_font helper that plugins are required to use) rather than sweeping the entire object graph.

This keeps the uninstall semantics (cancel plugin tasks, release plugin font handles) but removes the dependence on CPython GC internals and global introspection, making the behavior easier to reason about and maintain.

"""取消并等待属于指定插件模块的所有 asyncio tasks。"""
plugin_tasks = []
for task in asyncio.all_tasks():
if task.done():
continue
coro = task.get_coro()
frame = getattr(coro, "cr_frame", None)
if frame is None:
continue
mod_name = frame.f_globals.get("__name__", "")
if mod_name == plugin_module_prefix or mod_name.startswith(
plugin_module_prefix + "."
):
plugin_tasks.append(task)
Comment on lines +1575 to +1586
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

任务取消安全性与 Frame 兼容性:

  1. 防止自我取消: 如果卸载操作是由运行插件代码的 Task 触发的(例如插件自定义的命令或按钮),asyncio.all_tasks() 会包含当前 Task。如果当前 Task 的模块名正好匹配 plugin_module_prefix,它会把自己取消掉,导致卸载流程在半途异常中止。我们应该显式地在取消列表中排除 asyncio.current_task()
  2. Frame 兼容性: 仅检查 cr_frame 可能会遗漏基于生成器的协程(使用 gi_frame)。同时检查 cr_framegi_frame 可以确保所有匹配的 Task 都能被正确识别并清理。
Suggested change
for task in asyncio.all_tasks():
if task.done():
continue
coro = task.get_coro()
frame = getattr(coro, "cr_frame", None)
if frame is None:
continue
mod_name = frame.f_globals.get("__name__", "")
if mod_name == plugin_module_prefix or mod_name.startswith(
plugin_module_prefix + "."
):
plugin_tasks.append(task)
current_task = asyncio.current_task()
for task in asyncio.all_tasks():
if task.done() or task is current_task:
continue
coro = task.get_coro()
frame = getattr(coro, "cr_frame", None) or getattr(coro, "gi_frame", None)
if frame is None:
continue
mod_name = frame.f_globals.get("__name__", "")
if mod_name == plugin_module_prefix or mod_name.startswith(
plugin_module_prefix + "."
):
plugin_tasks.append(task)


if not plugin_tasks:
return

logger.debug(
f"取消插件 {plugin_module_prefix} 的 {len(plugin_tasks)} 个 asyncio tasks"
)
for task in plugin_tasks:
task.cancel()
try:
await asyncio.wait_for(
asyncio.gather(*plugin_tasks, return_exceptions=True),
timeout=5.0,
)
except asyncio.TimeoutError:
logger.warning(
f"插件 {plugin_module_prefix} 的部分 tasks 在 5 秒内未能取消,"
"可能导致文件句柄未释放。"
)
await asyncio.sleep(0)

def _release_plugin_font_handles(self, plugin, plugin_dir: str) -> None:
"""释放插件目录下字体文件的句柄,确保删除目录前文件不被占用。"""
import gc

from PIL import ImageFont

plugin.star_cls = None
plugin.module = None
plugin.star_cls_type = None

plugin_dir_norm = os.path.normcase(os.path.normpath(plugin_dir))
self_locals_id = id(locals())
for obj in gc.get_objects():
if not isinstance(obj, ImageFont.FreeTypeFont):
continue
font_path = getattr(obj, "path", None)
if not isinstance(font_path, str):
continue
if not os.path.normcase(os.path.normpath(font_path)).startswith(
plugin_dir_norm + os.sep
):
continue
logger.debug(f"释放字体句柄: {font_path}")
for referrer in gc.get_referrers(obj):
if not isinstance(referrer, dict) or id(referrer) == self_locals_id:
continue
for k in [k for k, v in referrer.items() if v is obj]:
referrer[k] = None

gc.collect()

async def uninstall_failed_plugin(
self,
dir_name: str,
Expand Down
54 changes: 52 additions & 2 deletions astrbot/core/utils/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,63 @@ def on_error(func, path, exc_info) -> None:
raise exc_info[1]


def _force_remove_file_windows(path: str, retries: int = 5, delay: float = 0.1) -> None:
"""在 Windows 上强制删除被占用的文件,带重试。"""
import stat

try:
os.chmod(path, stat.S_IWUSR | stat.S_IRUSR)
except Exception:
pass

last_exc = None
for attempt in range(retries):
try:
os.remove(path)
return
except PermissionError as e:
last_exc = e
if attempt < retries - 1:
time.sleep(delay)
except FileNotFoundError:
return

raise PermissionError(
f"无法删除文件(已重试 {retries} 次),文件可能仍被其他进程占用: {path}"
) from last_exc


def _on_error_windows(func, path, exc_info) -> None:
"""rmtree 的 Windows 错误回调,对被锁定的文件做重试。"""
import stat

exc = exc_info[1]
win_error = getattr(exc, "winerror", None)
if win_error in (5, 32) and func is os.unlink:
_force_remove_file_windows(path)
return

if not os.access(path, os.W_OK):
os.chmod(path, stat.S_IWUSR)
func(path)
else:
raise exc


def remove_dir(file_path: str) -> bool:
if not os.path.lexists(file_path):
return True
if os.path.isfile(file_path) or os.path.islink(file_path):
os.remove(file_path)
try:
os.remove(file_path)
except PermissionError:
if os.name == "nt":
_force_remove_file_windows(file_path)
else:
raise
else:
shutil.rmtree(file_path, onerror=on_error)
error_handler = _on_error_windows if os.name == "nt" else on_error
shutil.rmtree(file_path, onerror=error_handler)
return True


Expand Down
Loading