fix: 提升打包版桌面端启动稳定性并优化插件依赖处理 (#5031)

* fix(desktop): rotate electron and backend logs

* refactor(desktop): centralize log rotation defaults and debug fs errors

* fix(desktop): harden rotation fs ops and buffer backend log writes

* refactor(desktop): extract buffered logger and reduce sync stat calls

* refactor(desktop): simplify rotation flow and harden logger config

* fix(desktop): make app logging async and flush-safe

* fix: harden app log path switching and debug-gated rotation errors

* fix: cap buffered log chunk size during path switch

* fix: avoid redundant plugin reinstall and upgrade electron

* fix: stop webchat tasks cleanly and bind packaged backend to localhost

* fix: unify platform shutdown and await webchat listener cleanup

* fix: improve startup logs for dashboard and onebot listeners

* fix: revert extra startup service logs

* fix: harden plugin import recovery and webchat listener cleanup

* fix: pin dashboard ci node version to 24.13.0

* fix: avoid duplicate webchat listener cleanup on terminate

* refactor: clarify platform task lifecycle management

* fix: continue platform shutdown when terminate fails
This commit is contained in:
エイカク authored and GitHub committed 2026-02-12 01:04:04 +08:00
1 parent cd7755fe07
commit a8dda20a30
13 files changed
+227 -70

No files matched your search

+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 'latest'
node-version: '24.13.0'
- name: npm install, build
run: |
@@ -52,4 +52,4 @@ jobs:
repo: astrbot-release-harbour
body: "Automated release from commit ${{ github.sha }}"
token: ${{ secrets.ASTRBOT_HARBOUR_TOKEN }}
artifacts: "dashboard/dist.zip"
artifacts: "dashboard/dist.zip"
+2 -2
View File
@@ -57,7 +57,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
node-version: '24.13.0'
cache: "pnpm"
cache-dependency-path: dashboard/pnpm-lock.yaml
@@ -175,7 +175,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
node-version: '24.13.0'
cache: "pnpm"
cache-dependency-path: |
dashboard/pnpm-lock.yaml
+67 -20
View File
@@ -1,6 +1,7 @@
import asyncio
import traceback
from asyncio import Queue
from dataclasses import dataclass
from astrbot.core import logger
from astrbot.core.config.astrbot_config import AstrBotConfig
@@ -12,12 +13,19 @@ from .register import platform_cls_map
from .sources.webchat.webchat_adapter import WebChatAdapter
@dataclass
class PlatformTasks:
run: asyncio.Task
wrapper: asyncio.Task
class PlatformManager:
def __init__(self, config: AstrBotConfig, event_queue: Queue) -> None:
self.platform_insts: list[Platform] = []
"""加载的 Platform 的实例"""
self._inst_map: dict[str, dict] = {}
self._platform_tasks: dict[str, PlatformTasks] = {}
self.astrbot_config = config
self.platforms_config = config["platform"]
@@ -38,6 +46,44 @@ class PlatformManager:
sanitized = platform_id.replace(":", "_").replace("!", "_")
return sanitized, sanitized != platform_id
def _start_platform_task(self, task_name: str, inst: Platform) -> None:
run_task = asyncio.create_task(inst.run(), name=task_name)
wrapper_task = asyncio.create_task(
self._task_wrapper(run_task, platform=inst),
name=f"{task_name}_wrapper",
)
self._platform_tasks[inst.client_self_id] = PlatformTasks(
run=run_task,
wrapper=wrapper_task,
)
async def _stop_platform_task(self, client_id: str) -> None:
tasks = self._platform_tasks.pop(client_id, None)
if not tasks:
return
for task in (tasks.run, tasks.wrapper):
if not task.done():
task.cancel()
await asyncio.gather(tasks.run, tasks.wrapper, return_exceptions=True)
async def _terminate_inst_and_tasks(self, inst: Platform) -> None:
client_id = inst.client_self_id
try:
if getattr(inst, "terminate", None):
try:
await inst.terminate()
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(
"终止平台适配器失败: client_id=%s, error=%s",
client_id,
e,
)
logger.error(traceback.format_exc())
finally:
await self._stop_platform_task(client_id)
async def initialize(self) -> None:
"""初始化所有平台适配器"""
for platform in self.platforms_config:
@@ -51,12 +97,7 @@ class PlatformManager:
# 网页聊天
webchat_inst = WebChatAdapter({}, self.settings, self.event_queue)
self.platform_insts.append(webchat_inst)
asyncio.create_task(
self._task_wrapper(
asyncio.create_task(webchat_inst.run(), name="webchat"),
platform=webchat_inst,
),
)
self._start_platform_task("webchat", webchat_inst)
async def load_platform(self, platform_config: dict) -> None:
"""实例化一个平台"""
@@ -154,15 +195,9 @@ class PlatformManager:
"client_id": inst.client_self_id,
}
self.platform_insts.append(inst)
asyncio.create_task(
self._task_wrapper(
asyncio.create_task(
inst.run(),
name=f"platform_{platform_config['type']}_{platform_config['id']}",
),
platform=inst,
),
self._start_platform_task(
f"platform_{platform_config['type']}_{platform_config['id']}",
inst,
)
handlers = star_handlers_registry.get_handlers_by_event_type(
EventType.OnPlatformLoadedEvent,
@@ -230,13 +265,25 @@ class PlatformManager:
except Exception:
logger.warning(f"可能未完全移除 {platform_id} 平台适配器")
if getattr(inst, "terminate", None):
await inst.terminate()
await self._terminate_inst_and_tasks(inst)
async def terminate(self) -> None:
for inst in self.platform_insts:
if getattr(inst, "terminate", None):
await inst.terminate()
terminated_client_ids: set[str] = set()
for platform_id in list(self._inst_map.keys()):
info = self._inst_map.get(platform_id)
if info:
terminated_client_ids.add(info["client_id"])
await self.terminate_platform(platform_id)
for inst in list(self.platform_insts):
client_id = inst.client_self_id
if client_id in terminated_client_ids:
continue
await self._terminate_inst_and_tasks(inst)
self.platform_insts.clear()
self._inst_map.clear()
self._platform_tasks.clear()
def get_insts(self):
return self.platform_insts
@@ -26,14 +26,23 @@ from .webchat_queue_mgr import WebChatQueueMgr, webchat_queue_mgr
class QueueListener:
def __init__(self, webchat_queue_mgr: WebChatQueueMgr, callback: Callable) -> None:
def __init__(
self,
webchat_queue_mgr: WebChatQueueMgr,
callback: Callable,
stop_event: asyncio.Event,
) -> None:
self.webchat_queue_mgr = webchat_queue_mgr
self.callback = callback
self.stop_event = stop_event
async def run(self) -> None:
"""Register callback and keep adapter task alive."""
self.webchat_queue_mgr.set_listener(self.callback)
await asyncio.Event().wait()
try:
await self.stop_event.wait()
finally:
await self.webchat_queue_mgr.clear_listener()
@register_platform_adapter("webchat", "webchat")
@@ -56,6 +65,8 @@ class WebChatAdapter(Platform):
id="webchat",
support_proactive_message=False,
)
self._shutdown_event = asyncio.Event()
self._webchat_queue_mgr = webchat_queue_mgr
async def send_by_session(
self,
@@ -184,7 +195,7 @@ class WebChatAdapter(Platform):
abm = await self.convert_message(data)
await self.handle_msg(abm)
bot = QueueListener(webchat_queue_mgr, callback)
bot = QueueListener(self._webchat_queue_mgr, callback, self._shutdown_event)
return bot.run()
def meta(self) -> PlatformMetadata:
@@ -209,5 +220,4 @@ class WebChatAdapter(Platform):
self.commit_event(message_event)
async def terminate(self) -> None:
# Do nothing
pass
self._shutdown_event.set()
@@ -87,6 +87,19 @@ class WebChatQueueMgr:
for conversation_id in list(self.queues.keys()):
self._start_listener_if_needed(conversation_id)
async def clear_listener(self) -> None:
self._listener_callback = None
for close_event in list(self._queue_close_events.values()):
close_event.set()
self._queue_close_events.clear()
listener_tasks = list(self._listener_tasks.values())
for task in listener_tasks:
task.cancel()
if listener_tasks:
await asyncio.gather(*listener_tasks, return_exceptions=True)
self._listener_tasks.clear()
def _start_listener_if_needed(self, conversation_id: str):
if self._listener_callback is None:
return
+43 -10
View File
@@ -193,6 +193,37 @@ class PluginManager:
logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}")
return True
async def _import_plugin_with_dependency_recovery(
self,
path: str,
module_str: str,
root_dir_name: str,
requirements_path: str,
) -> ModuleType:
try:
return __import__(path, fromlist=[module_str])
except (ModuleNotFoundError, ImportError) as import_exc:
if os.path.exists(requirements_path):
try:
logger.info(
f"插件 {root_dir_name} 导入失败,尝试从已安装依赖恢复: {import_exc!s}"
)
pip_installer.prefer_installed_dependencies(
requirements_path=requirements_path
)
module = __import__(path, fromlist=[module_str])
logger.info(
f"插件 {root_dir_name} 已从 site-packages 恢复依赖,跳过重新安装。"
)
return module
except Exception as recover_exc:
logger.info(
f"插件 {root_dir_name} 已安装依赖恢复失败,将重新安装依赖: {recover_exc!s}"
)
await self._check_plugin_dept_update(target_plugin=root_dir_name)
return __import__(path, fromlist=[module_str])
@staticmethod
def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | None:
"""先寻找 metadata.yaml 文件,如果不存在,则使用插件对象的 info() 函数获取元数据。
@@ -386,6 +417,12 @@ class PluginManager:
"reserved",
False,
) # 是否是保留插件。目前在 astrbot/builtin_stars 目录下的都是保留插件。保留插件不可以卸载。
plugin_dir_path = (
os.path.join(self.plugin_store_path, root_dir_name)
if not reserved
else os.path.join(self.reserved_plugin_path, root_dir_name)
)
requirements_path = os.path.join(plugin_dir_path, "requirements.txt")
path = "data.plugins." if not reserved else "astrbot.builtin_stars."
path += root_dir_name + "." + module_str
@@ -400,11 +437,12 @@ class PluginManager:
# 尝试导入模块
try:
module = __import__(path, fromlist=[module_str])
except (ModuleNotFoundError, ImportError):
# 尝试安装依赖
await self._check_plugin_dept_update(target_plugin=root_dir_name)
module = __import__(path, fromlist=[module_str])
module = await self._import_plugin_with_dependency_recovery(
path=path,
module_str=module_str,
root_dir_name=root_dir_name,
requirements_path=requirements_path,
)
except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"插件 {root_dir_name} 导入失败。原因:{e!s}")
@@ -412,11 +450,6 @@ class PluginManager:
# 检查 _conf_schema.json
plugin_config = None
plugin_dir_path = (
os.path.join(self.plugin_store_path, root_dir_name)
if not reserved
else os.path.join(self.reserved_plugin_path, root_dir_name)
)
plugin_schema_path = os.path.join(
plugin_dir_path,
self.conf_schema_fname,
+20
View File
@@ -580,6 +580,26 @@ class PipInstaller:
)
importlib.invalidate_caches()
def prefer_installed_dependencies(self, requirements_path: str) -> None:
"""优先使用已安装在插件 site-packages 中的依赖,不执行安装。"""
if not is_packaged_electron_runtime():
return
target_site_packages = get_astrbot_site_packages_path()
if not os.path.isdir(target_site_packages):
return
requested_requirements = _extract_requirement_names(requirements_path)
if not requested_requirements:
return
_prepend_sys_path(target_site_packages)
_ensure_plugin_dependencies_preferred(
target_site_packages,
requested_requirements,
)
importlib.invalidate_caches()
async def _run_pip_in_process(self, args: list[str]) -> int:
pip_main = _get_pip_main()
_patch_distlib_finder_for_frozen_runtime()
+10 -6
View File
@@ -201,11 +201,16 @@ class AstrBotDashboard:
def run(self):
ip_addr = []
if p := os.environ.get("DASHBOARD_PORT"):
port = p
else:
port = self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185)
host = self.core_lifecycle.astrbot_config["dashboard"].get("host", "0.0.0.0")
port = (
os.environ.get("DASHBOARD_PORT")
or os.environ.get("ASTRBOT_DASHBOARD_PORT")
or self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185)
)
host = (
os.environ.get("DASHBOARD_HOST")
or os.environ.get("ASTRBOT_DASHBOARD_HOST")
or self.core_lifecycle.astrbot_config["dashboard"].get("host", "0.0.0.0")
)
enable = self.core_lifecycle.astrbot_config["dashboard"].get("enable", True)
if not enable:
@@ -213,7 +218,6 @@ class AstrBotDashboard:
return None
logger.info(f"正在启动 WebUI, 监听地址: http://{host}:{port}")
if host == "0.0.0.0":
logger.info(
"提示: WebUI 将监听所有网络接口,请注意安全。(可在 data/cmd_config.json 中配置 dashboard.host 以修改 host)",
+15 -2
View File
@@ -8,6 +8,7 @@ const { BufferedRotatingLogger } = require('./buffered-rotating-logger');
const {
delay,
ensureDir,
formatLogTimestamp,
normalizeUrl,
parseLogBackupCount,
parseLogMaxBytes,
@@ -391,6 +392,18 @@ class BackendManager {
};
if (this.app.isPackaged) {
env.ASTRBOT_ELECTRON_CLIENT = '1';
const hasExplicitDashboardHost = Boolean(
process.env.DASHBOARD_HOST || process.env.ASTRBOT_DASHBOARD_HOST,
);
const hasExplicitDashboardPort = Boolean(
process.env.DASHBOARD_PORT || process.env.ASTRBOT_DASHBOARD_PORT,
);
if (!hasExplicitDashboardHost) {
env.DASHBOARD_HOST = '127.0.0.1';
}
if (!hasExplicitDashboardPort) {
env.DASHBOARD_PORT = '6185';
}
}
if (backendConfig.webuiDir) {
env.ASTRBOT_WEBUI_DIR = backendConfig.webuiDir;
@@ -431,7 +444,7 @@ class BackendManager {
.map((item) => JSON.stringify(item))
.join(' ');
this.backendLogger.log(
`[${new Date().toISOString()}] [Electron] Start backend ${launchLine}\n`,
`[${formatLogTimestamp()}] [Electron] Start backend ${launchLine}\n`,
);
}
@@ -439,7 +452,7 @@ class BackendManager {
this.backendLastExitReason =
error instanceof Error ? error.message : String(error);
this.backendLogger.log(
`[${new Date().toISOString()}] [Electron] Backend spawn error: ${
`[${formatLogTimestamp()}] [Electron] Backend spawn error: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
+19
View File
@@ -81,11 +81,30 @@ function waitForProcessExit(child, timeoutMs = 5000) {
});
}
function formatLogTimestamp(date = new Date()) {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
const hour = `${date.getHours()}`.padStart(2, '0');
const minute = `${date.getMinutes()}`.padStart(2, '0');
const second = `${date.getSeconds()}`.padStart(2, '0');
const millisecond = `${date.getMilliseconds()}`.padStart(3, '0');
const offsetMinutes = -date.getTimezoneOffset();
const offsetSign = offsetMinutes >= 0 ? '+' : '-';
const absOffsetMinutes = Math.abs(offsetMinutes);
const offsetHour = `${Math.floor(absOffsetMinutes / 60)}`.padStart(2, '0');
const offsetMinute = `${absOffsetMinutes % 60}`.padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond} ${offsetSign}${offsetHour}${offsetMinute}`;
}
module.exports = {
LOG_ROTATION_DEFAULT_BACKUP_COUNT,
LOG_ROTATION_DEFAULT_MAX_MB,
delay,
ensureDir,
formatLogTimestamp,
isIgnorableFsError,
isLogRotationDebugEnabled,
normalizeUrl,
+6 -3
View File
@@ -2,7 +2,11 @@
const path = require('path');
const { RotatingLogWriter } = require('./rotating-log-writer');
const { parseLogBackupCount, parseLogMaxBytes } = require('./common');
const {
formatLogTimestamp,
parseLogBackupCount,
parseLogMaxBytes,
} = require('./common');
function createElectronLogger({ app, getRootDir }) {
const electronLogMaxBytes = parseLogMaxBytes(
@@ -28,7 +32,7 @@ function createElectronLogger({ app, getRootDir }) {
function logElectron(message) {
const logPath = getElectronLogPath();
const line = `[${new Date().toISOString()}] ${message}\n`;
const line = `[${formatLogTimestamp()}] ${message}\n`;
void writer.setLogPath(logPath);
void writer.append(line);
}
@@ -47,4 +51,3 @@ function createElectronLogger({ app, getRootDir }) {
module.exports = {
createElectronLogger,
};
+1 -1
View File
@@ -22,7 +22,7 @@
"dist": "pnpm run sync:version && electron-builder"
},
"devDependencies": {
"electron": "^30.0.0",
"electron": "^40.3.0",
"electron-builder": "^24.13.0"
},
"build": {
+14 -19
View File
@@ -9,8 +9,8 @@ importers:
.:
devDependencies:
electron:
specifier: ^30.0.0
version: 30.5.1
specifier: ^40.3.0
version: 40.3.0
electron-builder:
specifier: ^24.13.0
version: 24.13.3(electron-builder-squirrel-windows@24.13.3)
@@ -92,8 +92,8 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@20.19.33':
resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==}
'@types/node@24.10.13':
resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==}
'@types/node@25.2.2':
resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==}
@@ -397,8 +397,8 @@ packages:
electron-publish@24.13.1:
resolution: {integrity: sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==}
electron@30.5.1:
resolution: {integrity: sha512-AhL7+mZ8Lg14iaNfoYTkXQ2qee8mmsQyllKdqxlpv/zrKgfxz6jNVtcRRbQtLxtF8yzcImWdfTQROpYiPumdbw==}
electron@40.3.0:
resolution: {integrity: sha512-ZaDkTZpNHr863tyZHieoqbaiLI0e3RVCXoEC5y1Ld70/Q5H1mPV9d5TK0h1dWtaSFVOW0w8iDvtdLwAXtasXpg==}
engines: {node: '>= 12.20.55'}
hasBin: true
@@ -992,9 +992,6 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@@ -1158,7 +1155,7 @@ snapshots:
dependencies:
'@types/http-cache-semantics': 4.2.0
'@types/keyv': 3.1.4
'@types/node': 20.19.33
'@types/node': 25.2.2
'@types/responselike': 1.0.3
'@types/debug@4.1.12':
@@ -1173,13 +1170,13 @@ snapshots:
'@types/keyv@3.1.4':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.2.2
'@types/ms@2.1.0': {}
'@types/node@20.19.33':
'@types/node@24.10.13':
dependencies:
undici-types: 6.21.0
undici-types: 7.16.0
'@types/node@25.2.2':
dependencies:
@@ -1193,14 +1190,14 @@ snapshots:
'@types/responselike@1.0.3':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.2.2
'@types/verror@1.10.11':
optional: true
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.2.2
optional: true
'@xmldom/xmldom@0.8.11': {}
@@ -1597,10 +1594,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron@30.5.1:
electron@40.3.0:
dependencies:
'@electron/get': 2.0.3
'@types/node': 20.19.33
'@types/node': 24.10.13
extract-zip: 2.0.1
transitivePeerDependencies:
- supports-color
@@ -2211,8 +2208,6 @@ snapshots:
typescript@5.9.3: {}
undici-types@6.21.0: {}
undici-types@7.16.0: {}
universalify@0.1.2: {}