From 0664df162955dd9134ab1a74f886acce29a589e9 Mon Sep 17 00:00:00 2001 From: lingyun14 Date: Sun, 7 Jun 2026 18:05:18 +0800 Subject: [PATCH 1/3] Update io.py --- astrbot/core/utils/io.py | 54 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py index 08c9669d1a..6e84837e34 100644 --- a/astrbot/core/utils/io.py +++ b/astrbot/core/utils/io.py @@ -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 From e20e5681e6d70b226a1dc93ff768b90856bcc939 Mon Sep 17 00:00:00 2001 From: lingyun14 Date: Sun, 7 Jun 2026 18:06:01 +0800 Subject: [PATCH 2/3] Update star_manager.py --- astrbot/core/star/star_manager.py | 70 +++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 824c3b653b..ef4ef10f17 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -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)) @@ -1564,6 +1569,71 @@ async def uninstall_plugin( delete_data=delete_data, ) + async def _cancel_plugin_tasks(self, plugin_module_prefix: str) -> None: + """取消并等待属于指定插件模块的所有 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) + + 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)) + 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 + for referrer in gc.get_referrers(obj): + if not isinstance(referrer, dict): + 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, From 6c1ab20557537efd0f7ad73809e94493f282db69 Mon Sep 17 00:00:00 2001 From: lingyun14 Date: Sun, 7 Jun 2026 18:23:41 +0800 Subject: [PATCH 3/3] Update star_manager.py --- astrbot/core/star/star_manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index ef4ef10f17..30c90c8792 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -1616,6 +1616,7 @@ def _release_plugin_font_handles(self, plugin, plugin_dir: str) -> 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 @@ -1626,8 +1627,9 @@ def _release_plugin_font_handles(self, plugin, plugin_dir: str) -> None: plugin_dir_norm + os.sep ): continue + logger.debug(f"释放字体句柄: {font_path}") for referrer in gc.get_referrers(obj): - if not isinstance(referrer, dict): + 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