Bug 追蹤器
本文檔記錄 ErisPulse SDK 的已知 Bug 及其修復情況,按修復版本時間順序排列。
寫給讀者 沒有任何軟體天生完美,再細心的開發者也會留下小錯誤。本追蹤收錄的都是對運行有實際影響的問題——那些過於細微、連「輕微」等級都達不到的瑕疵並不會出現在這裡。清單中「嚴重」項看起來不少,但公開記錄這些 Bug 的初衷是讓排查與回溯更順暢,而非製造焦慮:能被看見、被記錄、被修復的問題,本身就是項目不斷變好的證明。看到這份清單不必緊張,它是一份排查工具,而不是恐懼的來源。
如何閱讀 & 維護約定
- 每條 Bug 記錄包含問題描述、根因分析、影響版本範圍、修復方案等結構化字段,建議升級前先檢索「影響版本」是否覆蓋當前使用的版本。
- 如需新增 Bug 條目,請在對應位置補充內容,遵循下文字段規範與嚴重性/類型分類。
字段說明
必填字段
| 字段 | 說明 |
|---|---|
| 問題 | Bug 的外在表現、用戶可觀察到的異常現象。盡量給出報錯資訊或典型場景 |
| 原因 | 根因分析,指向具體的代碼缺陷(含「根因鏈路」圖示用於複雜場景) |
| 影響版本 | 受影響的版本區間,格式 引入版本 - 修復版本(含兩端 dev 版本) |
| 修復版本 | 修復該 Bug 的具體版本號 |
| 修復內容 | 修復方案的簡要描述,含關鍵代碼變更點 |
| 修復日期 | 對應修復版本的發布日期,採用 YYYY/MM/DD 格式 |
| 嚴重性 | 按下文「嚴重性分級」標註 |
| 類型 | 按下文「類型分類」標註,可組合(如 適配器 / 路由) |
可選字段
| 字段 | 說明 | 適用場景 |
|---|---|---|
| 復現步驟 | 觸發該 Bug 的最小可復現路徑 | 複雜 Bug、偶發性 Bug 建議補充 |
| 關聯 | 相關 Issue / PR / Commit 鏈接 | 有外部討論記錄時補充 |
| 回歸測試 | 驗證修復、防止再次回歸的測試用例位置 | 已編寫對應 pytest 用例時補充 |
严重性分级
| 标识 | 级别 | 判定标准 | 典型表现 |
|---|---|---|---|
| 🔴 | 严重 | 导致进程崩溃、数据丢失/损坏、核心功能完全不可用、安全漏洞 | OOM Kill、消息无法发送、模块无法加载、热重载失败 |
| 🟡 | 中等 | 功能异常但有规避路径、非核心功能失效、偶发问题 | 状态判断错误、重复触发、缓存过期、错误提示不准 |
| 🟢 | 轻微 | 不影响核心功能、仅代码质量或体验问题、潜在风险未爆发 | 弃用 API、死代码、缺失 warning 日志 |
类型分类
| 类型 | 覆盖范围 |
|---|---|
| 配置系统 | ConfigManager、配置读写、配置 Schema、热更新 |
| 事件系统 | Event 模块(command/message/notice/request/meta)、事件分发、处理器注册 |
| 适配器 | AdapterManager、BaseAdapter、账户解析、Bot 状态、中间件 |
| 路由 | RouterManager、HTTP/WebSocket/SSE 路由、限流、CORS |
| 客户端 | HttpClient、ClientWebSocket、aiohttp 封装 |
| 存储 | StorageManager、SQLite、SQL 构建器、嵌套键 |
| 加载系统 | Loader、LazyModule、ModuleInitializer、严格模式、模块发现 |
| CLI | epsdk 命令、init/run/install、参数解析、信号处理 |
| 运行时 | sdk.run/restart/uninit、生命周期、信号、子进程 |
条目模板
新增 Bug 条目请遵循以下格式:
### [BUG-XXX] 标题
**问题**: 问题描述(报错信息或典型现象)
**原因**: 根因分析
**影响版本**: 引入版本 - 修复版本
**修复版本**: x.x.x
**修复内容**: 修复方案
**修复日期**: YYYY/MM/DD
<!-- 可选字段 -->
**复现步骤**: (复杂 Bug 建议补充)
**关联**: (Issue/PR 链接)
**回归测试**: (验证用例路径)
**严重性**: 🔴 严重 | 🟡 中等 | 🟢 轻微
**类型**: 配置系统 / 事件系统 / 适配器 / 路由 / 客户端 / 存储 / 加载系统 / CLI / 运行时
统计概览
| 严重性 | 数量 |
|---|---|
| 🔴 严重 | 16 |
| 🟡 中等 | 18 |
| 🟢 轻微 | 3 |
| 合计 | 37 |
| 类型 | 数量 |
|---|---|
| 适配器 | 6 |
| 配置系统 | 11 |
| 事件系统 | 7 |
| CLI | 3 |
| 存储 | 3 |
| 加载系统 | 3 |
| 路由 | 2 |
| 客户端 | 1 |
| 运行时 | 1 |
注:单条 Bug 可归属多个类型,上表按主类型统计。
注:BUG-028 / BUG-031 编号空缺(登记时废弃,为保持既有编号稳定不回收重排)。
已修复的 Bug
[BUG-001] 事件处理器重复注册导致事件被多次处理
问题: 使用多个 @message / @notice 等装饰器注册处理器时,同一事件会被重复触发多次,导致命令被执行多遍、日志重复输出。
原因: BaseEventHandler 向适配器事件总线注册处理器时缺少去重逻辑,每个装饰器都会向总线挂载一次,事件分发时被多次调用。
影响版本: 2.2.0-dev.0 - 2.2.1-dev.0
修复版本: 2.2.1-dev.0
修复内容: 优化 BaseEventHandler,确保每个事件类型只向适配器注册一次处理器,避免重复触发。
修复日期: 2025/08/18
严重性: 🔴 严重
类型: 事件系统
[BUG-002] Init 命令适配器配置路径类型错误
问题: 使用 ep init 命令进行交互式初始化时,选择配置适配器会出现类型错误:
交互式初始化失败: unsupported operand type(s) for /: 'str' and 'str'
原因: 2.3.7 版本调整配置文件路径时,方法参数类型不一致。_configure_adapters_interactive_sync 接收 str 类型参数,但内部使用 Path 的 / 操作符拼接路径。
影响版本: 2.3.7 - 2.3.9-dev.1
修复版本: 2.3.9-dev.1
修复内容: 将 _configure_adapters_interactive_sync 方法的参数类型从 str 改为 Path,调用时直接传递 Path 对象。
修复日期: 2026/03/23
严重性: 🟡 中等
类型: CLI
[BUG-003] 重启后命令事件失效
问题: 调用 sdk.restart() 后,通过 @command 注册的命令无法被触发,表现为发送命令后机器人无响应。
原因: adapter.shutdown() 清空事件总线后,BaseEventHandler 的 _linked_to_adapter_bus 状态未重置为 False,导致 _process_event 方法认为已经挂载到适配器总线,跳过重新挂载操作。
影响版本: 2.2.x - 2.4.0-dev.2
修复版本: 2.4.0-dev.3
修复内容: 引入 _linked_to_adapter_bus 状态追踪,_clear_handlers() 断开总线连接后,下次 register() 自动重新挂载,适配 shutdown/restart 场景。
修复日期: 2026/04/09
严重性: 🔴 严重
类型: 事件系统
[BUG-004] 生命周期事件处理器未清理
问题: sdk.restart() 后,旧的生命周期事件处理器仍然存在并重复触发,导致同一个事件被多次处理。
原因: lifecycle._handlers 字典在 uninit() 时从未被清理,restart 后旧处理器与新处理器同时存在。
影响版本: 2.3.0 - 2.4.0-dev.2
修复版本: 2.4.0-dev.3
修复内容: 在 Uninitializer 的清理流程末尾(所有事件提交之后),清空 lifecycle._handlers。
修复日期: 2026/04/09
严重性: 🟡 中等
类型: 运行时
[BUG-005] Event.is_friend_add/is_friend_delete 的 detail_type 与 OB12 标准不一致
问题: Event.is_friend_add() 检查 detail_type == "friend_add",Event.is_friend_delete() 检查 detail_type == "friend_delete",但 OneBot12 标准定义的 detail_type 值为 "friend_increase" 和 "friend_decrease"。与 notice.py 中 on_friend_add/on_friend_remove 装饰器使用的值不一致,导致通过装饰器注册的处理器触发时,对应的 is_friend_add()/is_friend_delete() 判断方法返回 False。
原因: wrapper.py 中使用了非标准的命名,而 notice.py 使用了正确的 OB12 标准命名。
影响版本: 实装至今
修复版本: 2.4.2-dev.1
修复内容: 将 is_friend_add() 的匹配值从 "friend_add" 改为 "friend_increase",is_friend_delete() 从 "friend_delete" 改为 "friend_decrease"。
修复日期: 2026/04/13
严重性: 🟡 中等
类型: 事件系统
[BUG-006] adapter.clear() 未清理 _started_instances 导致重启后状态不正确
问题: AdapterManager.clear() 方法清除了 _adapters、_adapter_info、处理器和 _bots,但遗漏了 _started_instances 集合。如果适配器正在运行时调用 clear(),_started_instances 会保留悬空引用,导致重启后状态判断错误。
原因: 2.4.0-dev.1 引入 _started_instances 时未在 clear() 中同步清理。
影响版本: 2.4.0-dev.1 - 2.4.2-dev.0
修复版本: 2.4.2-dev.1
修复内容: 在 clear() 方法中添加 self._started_instances.clear()。
修复日期: 2026/04/13
严重性: 🟡 中等
类型: 适配器
[BUG-007] command.wait_reply() 使用已弃用的 asyncio.get_event_loop()
问题: CommandHandler.wait_reply() 方法使用 asyncio.get_event_loop() 创建 future 和获取时间戳,该方法在 Python 3.10+ 中已弃用,在异步上下文中应使用 asyncio.get_running_loop()。与同文件中 wrapper.py 的 wait_for() 方法使用的 get_running_loop() 不一致。
原因: 开发时使用了旧版 API,后续新增的 wait_for() 使用了正确的 API 但未回溯修复旧代码。
影响版本: 2.3.0-dev.0
修复版本: 2.4.2-dev.1
修复内容: 将 command.py 中两处 asyncio.get_event_loop() 替换为 asyncio.get_running_loop()。
修复日期: 2026/04/13
严重性: 🟢 轻微
类型: 事件系统
[BUG-008] Bot 离线事件在 shutdown 过程中被重复提交
问题: 调用 adapter.shutdown() 关闭所有适配器时,_update_bot_status() 会在关闭流程中反复提交 Bot 离线事件,导致同一批 Bot 被多次标记离线并触发多次 adapter.bot.offline 生命周期事件。
原因: 2.4.0-dev.1 引入的 Bot 状态追踪系统未在 shutdown() 期间设置"正在关闭"标志,_update_bot_status() 无法区分正常离线与关闭流程中的级联离线。
影响版本: 2.4.0-dev.1 - 2.4.2-dev.1
修复版本: 2.4.2-dev.1
修复内容: 在 AdapterManager 中新增 _is_being_shutdown 标志,shutdown() 开始时置为 True、结束时清除;_update_bot_status() 检查该标志后跳过关闭过程中的重复提交。
修复日期: 2026/04/21
严重性: 🟡 中等
类型: 适配器
[BUG-009] LazyModule 同步访问 BaseModule 导致未初始化完成
问题: 用户在同步上下文中访问懒加载的 BaseModule 属性时,模块使用 loop.create_task() 异步初始化但不等待,导致属性访问时可能未初始化完成,引发竞态条件。
原因: _ensure_initialized() 对 BaseModule 使用 loop.create_task(self._initialize()) 后立即返回,未确保初始化完成。
影响版本: 2.4.0-dev.0 - 2.4.2-dev.1
修复版本: 2.4.2-dev.2
修复内容: 在同步上下文中,BaseModule 的初始化改为使用 asyncio.run(self._initialize()),确保初始化完成后再返回。保持透明代理特性,用户无需感知同步/异步差异。
修复日期: 2026/04/21
严重性: 🟡 中等
类型: 加载系统
[BUG-010] 配置系统多线程写入导致数据丢失
问题: 在多线程环境下,多个线程同时调用 config.setConfig() 时,_flush_config() 读取-修改-写入操作不是原子性的,可能导致部分写入丢失。
原因: _flush_config() 虽然使用了 RLock,但文件读取和写入之间没有文件锁保护,且 _schedule_write 的 Timer 可能被多次触发导致覆盖。
影响版本: 2.3.0 - 2.4.2-dev.1
修复版本: 2.4.2-dev.2
修复内容:
- 添加文件锁机制(
_file_lock)确保文件操作原子性 - 使用临时文件写入后原子性重命名(
os.replace/os.rename) - 改进
_schedule_write的 Timer 取消和重新调度逻辑
修复日期: 2026/04/21
严重性: 🔴 严重
类型: 配置系统
[BUG-011] Windows 下 CTRL+C 无法停止程序
问题: 在 Windows 上直接运行 python main.py 时,按下 CTRL+C 无法终止程序。程序正常启动并输出路由服务器信息后,CTRL+C 完全无响应,只能通过任务管理器强杀进程。而通过 epsdk run 启动时可以正常停止——但 epsdk run 是通过子进程模型运行的。
原因: Hypercorn ASGI 服务器的 serve() 函数内部通过 signal.signal(SIGINT, handler) 注册了自己的 SIGINT 处理器,覆盖了 Python 默认的 KeyboardInterrupt 处理机制。当通过 asyncio.create_task() 启动 Hypercorn 作为后台任务时,Hypercorn 的内部 shutdown 流程无法正常触发(因为它期望的是 worker_serve 模式),导致 CTRL+C 信号被 Hypercorn 吞掉但不会引发任何清理动作。
影响版本: 2.3.6 - 2.4.2
修复版本: 2.4.3-dev.0
修复内容:
- 将 ASGI 服务器从 Hypercorn 切换为 Uvicorn(
pyproject.toml依赖变更) - 使用
uvicorn.Server._serve()直接启动服务器,绕过capture_signals()信号处理上下文管理器 - 通过
server.should_exit = True实现优雅停止,超时则取消后台任务 - 同步移除子进程运行模型和
runtime/cleanup.py清理模块(子进程清理机制不再需要)
修复日期: 2026/04/28
严重性: 🔴 严重
类型: CLI / 运行时
[BUG-012] 热重启后已更新模块的 Python 代码未生效
问题: 执行 sdk.restart() 软重启后,已通过 epsdk install 升级的模块/适配器的新代码(如新增 API 路由)不生效,仍运行旧版本逻辑。必须完全重启进程才能加载最新代码。
原因: _do_restart() 在重新初始化时调用 entry_point.load(),但该函数从 sys.modules 返回了缓存的旧版本模块对象,而非从磁盘重新加载。
影响版本: 早期版本 - 2.4.3-dev.1
修复版本: 2.4.3-dev.1
修复内容: 在 uninit() 后、init() 前清理 sys.modules 中已加载模块/适配器包的缓存,使 entry_point.load() 从磁盘加载最新代码。新增 _collect_top_level_modules() 与 _invalidate_module_cache() 辅助方法,通过 top_level.txt 或 entry-point value 推导顶层模块名。
修复日期: 2026/05/03
严重性: 🔴 严重
类型: 加载系统 / 运行时
[BUG-013] 模块加载策略排序逻辑错误
问题: ModuleLoadStrategy 提供了 priority 字段用于声明模块的初始化优先级,但加载策略的实现存在失误,导致模块未按预期的优先级顺序初始化,实际按 entry_points() 的默认顺序加载。当模块间存在加载依赖时,无法通过 priority 确保正确的初始化先后关系。
原因: 加载策略的实现中排序逻辑有误,initialize_modules() 未使用 priority 对模块列表进行排序。
影响版本: 2.3.4 - 2.4.5-dev.2
修复版本: 2.4.5-dev.3
修复内容: 在 initialize_modules() 遍历前,按 priority 降序排序模块列表。同 priority 的模块保持原有相对顺序(稳定排序)。
修复日期: 2026/05/15
严重性: 🟡 中等
类型: 加载系统
[BUG-014] 适配器中间件返回 None 导致事件数据丢失
问题: adapter.emit() 在执行 OneBot12 中间件链时,如果某个中间件返回 None(例如忘记 return data),后续中间件和所有事件处理器收到的 processed_data 变为 None,导致事件处理完全失效。
原因: 中间件链的实现 processed_data = await middleware(processed_data) 未检查返回值是否为 None,直接覆盖了上一步的处理结果。
影响版本: unknown - 2.4.5-dev.3
修复版本: 2.4.5-dev.4
修复内容: 中间件返回 None 时忽略该返回值,保留原数据继续传递,并输出 warning 级别日志。
修复日期: 2026/05/15
严重性: 🔴 严重
类型: 适配器 / 事件系统
[BUG-015] 配置文件路径依赖工作目录
问题: ConfigManager 的配置文件路径默认为相对路径 "config/config.toml",在运行时依赖 os.getcwd() 解析。如果工作目录在运行期间发生变化(例如通过 os.chdir()),配置文件的读写操作会指向错误的位置,导致配置丢失或读取到旧数据。
原因: __init__ 中直接存储相对路径,未在初始化时将其解析为绝对路径。
影响版本: 2.3.7 - 2.4.5-dev.3
修复版本: 2.4.5-dev.4
修复内容: 在 ConfigManager.__init__() 中,如果传入的路径为相对路径,自动通过 os.path.abspath() 解析为绝对路径。
修复日期: 2026/05/15
严重性: 🟡 中等
类型: 配置系统
[BUG-016] BaseStorage 将存储值 None 与键不存在混淆
问题: BaseStorage.get_multi() / __getattr__() 无法区分"键不存在"与"键的值就是 None"两种情况,用户显式存入 None 后再读取时会被当作键不存在处理。
原因: 取值逻辑直接用 value is None 判断键是否存在,缺少独立的"缺失"标记。
影响版本: 早期版本 - 2.4.6-dev.6
修复版本: 2.4.6-dev.6
修复内容: 引入 _SENTINEL 哨兵值区分"键不存在"与"值为 None",二者不再混淆。
修复日期: 2026/06/07
严重性: 🟡 中等
类型: 存储
[BUG-017] WebSocket 路由 auto_accept 标志在服务重启后丢失
问题: 服务重启(如 sdk.restart())后,所有 WebSocket 路由的 auto_accept 配置都变回 False,原本期望自动 accept 的连接变为挂起状态,客户端长时间收不到响应,表现为 WS 连接卡死。
原因: _restore_routes_from_records() 在从持久化记录恢复路由时把 auto_accept 硬编码为 False,未读取原始记录中的值;同时路由存储元组也从二元组扩展为三元组时未同步更新恢复逻辑。
影响版本: 2.3.8-dev.0 - 2.4.6-dev.6
修复版本: 2.4.6-dev.6
修复内容: 路由存储元组扩展为 (handler, auth_handler, auto_accept),_restore_routes_from_records() 从记录读取真实 auto_accept 值而非硬编码 False。
修复日期: 2026/06/07
严重性: 🔴 严重
类型: 路由
[BUG-018] HTTP/WS 客户端并发调用导致崩溃与连接泄漏
问题: Core/client.py 的 HTTP 与 WebSocket 客户端在并发场景下存在多个稳定性缺陷,会导致连接泄漏或进程崩溃:
- 多协程并发调用
ClientWebSocket.receive()时 aiohttp 抛出Concurrent call to receive() is not allowed _get_http_session()/_get_ws_session()并发调用可能创建多个 session 且_drain_sessions()未关闭旧连接,造成连接泄漏request()的异常捕获顺序错误:except ClientConnectionError(ErisPulse 异常)永不触发,aiohttp 的连接错误被通用except Exception接住,导致"连接重试 + session 重建"逻辑(死代码)从未执行send_json()忽略mode="binary"参数;_get_ws_session()未传入默认请求头
原因: 客户端初次实现(2.4.6-dev.5)缺少并发保护与异常分类,对 aiohttp 异常体系与 ErisPulse 自定义异常的继承关系处理不当。
影响版本: 2.4.6-dev.5 - 2.4.8
修复版本: 2.4.8
修复内容:
- 新增
_recv_lock序列化所有receive()/receive_text()/receive_bytes()调用 - 新增
_session_lock保护 session 创建;_drain_sessions()改为异步方法并真正关闭旧 session - 重构
request()异常捕获顺序:asyncio.TimeoutError→aiohttp.ClientConnectionError(触发 session 重建)→aiohttp.ClientError→ClientError(透传)→Exception - 修复
send_json()的 mode 处理、_get_ws_session()默认请求头透传、close()的并发竞态、HttpResponse.__aexit__重复release()
修复日期: 2026/06/12
严重性: 🔴 严重
类型: 客户端
[BUG-019] 适配器热重载时路由冲突导致重载失败
问题: 第三方模块(如 Dashboard)触发适配器热重载,或适配器启动失败重试时,因上次注册的旧路由(如 onebot11_default)未清理,抛出 WebSocket路径 ... 已注册 冲突,导致重载失败。需要完全重启进程才能恢复。
原因: AdapterManager.shutdown() 仅以 unregister_all_by_namespace(platform) 清理路由,但适配器(如 OneBot11)以 onebot11_{account_name} 为命名空间注册 WS 路由,颗粒度不匹配导致清理为空操作;启动失败重试路径也未清理上次残留路由。
影响版本: 早期版本 - 2.4.9
修复版本: 2.4.9
修复内容:
- 路由注册时通过
current_ownerContextVar 自动追踪owner → namespace归属关系 - 新增
unregister_all_by_owner(owner),停止/重启时同时按 owner 清理,覆盖细颗粒度命名空间 - 新增
_stop_adapter(platform)原语("停止即清理"),将停止适配器与回收其注册的资源绑定在一次调用里,restart()和启动失败重试均经此入口 - 新增框架级
adapter.restart(platform)API,第三方模块应调用此方法而非直接操作适配器实例
修复日期: 2026/06/12
严重性: 🔴 严重
类型: 适配器 / 路由
[BUG-020] 子进程模式 ep run <script> 找不到脚本所在目录的子包
问题: 使用 ep r .\main.py 非热重载模式运行脚本时,如果脚本有相对导入(如 from qg import ...),会报 No module named 'qg' 错误。而 --reload 模式可以正常运行。
原因: 非热重载模式直接调用 runpy.run_path() 执行脚本,该函数不会自动将脚本所在目录加入 sys.path。而 --reload 模式通过 subprocess.Popen 子进程运行,子进程自动继承当前工作目录,sys.path[0] 即为脚本所在目录,所以能正常工作。
影响版本: 2.5.0 - 2.5.2-dev.0
修复版本: 2.5.2-dev.0
修复内容: 在 runpy.run_path() 调用前,手动将脚本所在目录插入 sys.path[0]。
修复日期: 2026/06/27
严重性: 🟡 中等
类型: CLI
[BUG-021] SQL 查询构建器拒绝合法通配符和列表达式
问题: SQLiteQueryBuilder 的 _build_select_sql() 對所有 SELECT 列調用 _validate_identifier(),該函數使用嚴格的白名單正則 ^[a-zA-Z_][a-zA-Z0-9_]*$,導致合法 SQL 語法被誤判為不安全列名:
SELECT *—*是 SQL 標準通配符SELECT COUNT(*)— 聚合函數SELECT users.name— 限定列名SELECT col AS alias— 列別名
其中 Select("*") 被 Cron 等模塊使用,導致模塊 on_load 執行失敗,模塊無法加載。
原因: 2.4.6 版本增強了 SQL 注入防護,引入了 _validate_identifier() 白名單校驗。該校驗應用於所有列名,但未區分讀取端(SELECT/ORDER BY)和寫入端(INSERT/UPDATE)。SELECT 列允許複雜的 SQL 表達式,不應受簡單標識符白名單限制。
影響版本: 2.4.6 - 2.5.2-dev.1
修復版本: 2.5.2-dev.2
修復內容: 將 SELECT/ORDER BY 的列校驗從白名單模式改為黑名單模式:
- 新增
_validate_select_column()函數,僅攔截 SQL 注入危險字符(;'"--/**/\x00換行符) - 允許任意合法 SQL 列表達式(
*、table.*、table.column、COUNT(*)、col AS alias等) - INSERT/UPDATE 列名仍保持嚴格白名單校驗(僅允許簡單標識符)
修復日期: 2026/06/29
嚴重性: 🔴 嚴重
類型: 存儲
[BUG-022] _resolve_account() 賬戶解析回歸(_accounts_data 未填充)
問題: 2.5.2 配置系統重構後,聲明了 AccountConfigClass 的多賬戶適配器在調用 wait_reply、reply 等需要發送消息的方法時,報錯 ValueError("未聲明 AccountConfigClass,無法解析賬戶")。即使適配器正確配置了多賬戶信息,賬戶解析仍然失敗。
原因: 2.5.2-dev.5 將 _load_accounts()(負責讀取配置 + 校驗 + 填充 _accounts_data)重構為 _ensure_accounts_exist()(僅生成配置模板),但 _resolve_account() 仍檢查 self._accounts_data is None。由於 _ensure_accounts_exist() 不再填充 _accounts_data,該屬性始終為 None,導致 _resolve_account() 提前返回 (None, None),賬戶解析完全失效。
根因鏈路:
_load_accounts() 被刪除
→ __init__ 不再填充 _accounts_data
→ _accounts_data 恆為 None
→ _resolve_account() 檢查 _accounts_data is None → return (None, None)
→ 下游調用 _resolve_account 的地方(如 call_api)拿到 None
→ 觸發報錯
影響版本: 2.5.2-dev.5 - 2.5.2
修復版本: 2.5.3
修復內容: 在 BaseAdapter.__init__ 中,_ensure_accounts_exist() 之後恢復 _accounts_data 的填充:
if self.AccountConfigClass is not None:
self._ensure_accounts_exist()
self._accounts_data = self.accounts # 恢復填充,數據源為實時讀取的 accounts 屬性
_resolve_account() 逻辑保持不变,完全向后兼容:
- 不声明
AccountConfigClass的适配器:_accounts_data保持None→ 返回(None, None) - 声明了
AccountConfigClass的适配器:_accounts_data被填充 → 正常解析 - 覆写
_load_accounts或手动设置_accounts_data的适配器:在super().__init__()后覆盖,优先级最高
修复日期: 2026/07/07
严重性: 🔴 严重
类型: 适配器 / 配置系统
[BUG-023] 修改账户配置后适配器缓存未刷新导致账户解析失败
问题: 用户通过 Dashboard 修改多账户适配器的账户配置(如填写 token)后,适配器仍使用旧缓存,调用发送消息相关方法时报 未找到可用账户 (account_id=default)。必须重启进程才能让新配置生效。
原因: _accounts_data 仅在 BaseAdapter.__init__ 时从配置存储读取一次,之后不再刷新。AdapterManager._run_adapter() 与 restart() 在调用 adapter.start() 前未重新读取账户配置,导致缓存与实际配置脱节。
影响版本: 2.4.6 - 2.5.4
修复版本: 2.5.4
修复内容: 在 AdapterManager._run_adapter() 和 restart() 中,调用 adapter.start() 之前刷新 adapter._accounts_data = adapter.accounts,确保每次启动时使用最新配置。
修复日期: 2026/07/09
严重性: 🔴 严重
类型: 适配器 / 配置系统
[BUG-024] storage.set() 写入大数字 ID 键时触发 OOM Kill
问题: 调用 storage.set() 写入包含大纯数字段(如 QQ 群号 871684833)的嵌套键路径时,进程被容器 OOM Kill(退出码 -9),服务直接崩溃无法恢复。
原因: _set_nested_value 的递归实现中,嵌套键路径里的纯数字段被 isdigit() 误判为列表索引,触发 current.extend([None] * (index - len(current) + 1)),试图分配数亿元素的列表,瞬间耗尽内存。
根因链路:
键路径包含纯数字段(如群号 871684833)
→ isdigit() 误判为数组索引
→ extend([None] * (871684833 - len(current) + 1))
→ 尝试分配数亿元素
→ 内存耗尽 → 容器 OOM Kill(退出码 -9)
影响版本: 2.5.1 - 2.5.5
修复版本: 2.5.5
修复内容:
- 预创建中间层时始终使用字典,不再根据下一段是否为数字猜测容器类型
- 设置最终值时,仅当容器本身已是列表且索引小于
STORAGE_MAX_LIST_INDEX(10000)时才按索引处理,超大索引安全跳过 - 将递归实现改为迭代实现,消除原代码中潜在的无限递归风险
- 新增
STORAGE_MAX_LIST_INDEX常量到Core/constants.py,集中管理索引安全上限
修复日期: 2026/07/10
复现步骤:
# 写入包含大数字段(如 QQ 群号)的嵌套键路径即可触发
await sdk.storage.aset("groups.871684833.name", "某群")
# → 进程内存瞬间飙升,被 OOM Kill
回归测试: tests/unit/test_unit_storage.py 新增 4 个回归用例
test_nested_key_numeric_segment_as_dict_key— 精确复现 OOM 场景test_nested_key_numeric_segment_multiple— 多个连续数字段均作为字典键test_nested_key_existing_list_index_set_within_limit— 已有列表合理索引写入test_nested_key_list_index_safety_limit— 超大索引安全限制验证
严重性: 🔴 严重
类型: 存储
[BUG-025] on_config_update 回调未被核心路由
问题: on_config_update(old, new) 回调在基类(BaseModule / BaseAdapter)中已定义,但框架核心未将其与配置变更事件关联。实际表现:通过配置管理面板改配置时可以触发,而手动编辑 config.toml 或代码调用 setConfig() 时不会触发 on_config_update。
原因: ConfigManager 在配置变更时会发射 config.set / config.updated 生命周期事件,但缺少将这些事件转发到各组件 on_config_update 方法的订阅逻辑。
根因链路:
核心未订阅 config.set / config.updated
→ 配置变更事件无转发
→ on_config_update 未被调用
→ 手动编辑文件 / 代码 setConfig 不触发热更新回调
影响版本: 全版本
修复版本: 2.6.2
修复内容: ModuleManager / AdapterManager 注册 config.set(覆盖代码 setConfig() 路径)与 config.updated(覆盖手动编辑文件路径)事件订阅,按配置键前缀匹配后调用对应组件的 on_config_update,传入类型安全的配置对象。同时修复 _flush_config() 写入文件后未同步 _config_mtime 的问题,避免框架自身写入被文件监听任务误判为外部修改而重复触发 config.updated。
兼容性说明: 配置热更新现由框架核心统一维护。此前由配置管理面板代为触发的逻辑已移除,升级框架后需同步升级配置管理面板,否则会出现重复触发(核心 + 面板各调一次)。on_config_update 方法签名与语义保持不变,子类无需修改。
修复日期: 2026/07/23
严重性: 🟡 中等
类型: 配置系统
[BUG-026] notice/request 事件 reply 目标推断错误
问题: 在群通知事件(如成员加群 group_member_increase)中调用 event.reply(),消息被发送到触发事件的用户私聊,而非事件所在的群。好友通知事件同理,回复目标可能错乱。
原因: infer_receive_type() 将事件的 detail_type 直接当作会话类型返回。对于 message 事件这是正确的(detail_type 值 private/group 即会话类型),但 notice/request 事件的 detail_type 是语义子类型(如 group_member_increase、friend_increase),不是会话类型。后续的 convert_to_send_type() 和 get_id_field() 在映射表中找不到该值,回退到默认的 "user" / "user_id",导致回复目标错乱。
根因链路:
notice 事件 detail_type="group_member_increase"
→ infer_receive_type() 直接返回 "group_member_increase"
→ convert_to_send_type("group_member_increase") 不在映射表 → 回退 "user"
→ get_id_field("group_member_increase") 不在映射表 → 回退 "user_id"
→ target_id = event["user_id"] ← 新成员私聊(而非群)
影响版本: 全版本
修复版本: 2.7.0-dev.3
修复内容: infer_receive_type() 增加判断——detail_type 只有在是已知会话类型(标准类型或自定义类型)时才直接返回;否则根据 ID 字段(group_id / channel_id / user_id 等)推断正确的会话类型。
回归测试: tests/unit/test_unit_session_type.py → TestNoticeRequestTypeInference(10 用例)
修复日期: 2026/07/29
严重性: 🟢 轻微
类型: 事件系统
[BUG-027] 路由限流清理任务使用固定窗口导致长窗口限流规则失效
问题: 将路由限流配置为长窗口规则(如 100/hour、{"requests": 100, "window": 3600})时,限流形同虚设——实际表现近似 100/minute(每小时可放过至约 6000 次请求),完全无法起到预期的小时级防护作用。
原因: _apply_rate_limit 解析得到每路由的实际 window(最高 3600 秒),per-request 检查也确实使用该窗口;但后台清理任务 _cleanup_expired_rate_limits 却用固定常量 DEFAULT_RATE_LIMIT_WINDOW_SECS(60 秒)作为所有路由的统一清理阈值。于是 100/hour 路由中早于 60 秒的时间戳被清理任务提前清除,小时窗口内永远累积不到接近 100 条记录,限流被严重削弱。
根因链路:
_apply_rate_limit 解析 window=3600(100/hour)
→ per-request 檢查按 3600s 保留時間戳(正確)
→ 但 _cleanup_expired_rate_limits 用固定 max_window=60s 清理
→ 60s 前的時間戳被全部清除
→ 小時窗口永遠只餘最近 1 分鐘的記錄
→ 100/hour 實際退化為 ~100/minute(寬放約 60 倍)
影响版本: 2.6.0-dev.0 - 2.7.0-dev.4
修复版本: 2.7.0-dev.5
修复内容: 新增 _rate_limit_windows: dict[str, int] 按 store key 记录每路由实际窗口;_apply_rate_limit 首次创建条目时写入窗口;_cleanup_expired_rate_limits 改为按各 key 自身窗口清理(缺失时回退默认值);清理删除条目与 stop() 时同步维护两个字典。
修复日期: 2026/07/31
回归测试: tests/unit/test_unit_router.py → TestRateLimit::test_cleanup_respects_per_route_window
严重性: 🔴 严重
类型: 路由
[BUG-029] 配置监听任务广播半成品 TOML 并静默吞掉异常
问题: 用户手动编辑 config.toml 保存到一半(产生瞬时的语法错误)时,配置监听后台线程会检测到 mtime 变化、重载配置,但加载失败后仍以空配置 {} 发射 config.updated 事件,导致适配器/模块的 on_config_update 收到空配置、误以为所有配置项被清空而回退默认值。此外监听循环用 except Exception: pass 静默吞掉所有异常,watcher 故障无从排查。
原因: 两个缺陷叠加:
_load_config在 TOML 语法错误/权限错误时把self._cache擦写为{},但后台监听线程_watch_loop与缓存超时路径_check_cache_validity都在调用_load_config()后无条件执行_emit_config_updated(),把"加载失败产生的空缓存"当作真实变更广播。_watch_loop的except Exception改为以 warning 级别记录(新增 i18n 键core.config.watcher_error,五语言同步)
根因链路:
用戶保存到一半 → TOML 語法錯誤
→ _load_config() 擦寫 _cache = {}
→ _watch_loop 無條件 _emit_config_updated(new_config={})
→ 適配器/模塊 on_config_update 收到空配置
→ 誤判配置被清空,回退預設值
影响版本: 2.6.2-dev.1 - 2.7.0-dev.4
修复版本: 2.7.0-dev.5
修复内容:
_load_config改为返回bool;TOML 语法错误/权限/其他错误时保留上次有效缓存(不再擦写为{}),仅记录诊断日志并返回False_watch_loop与_check_cache_validity仅在_load_config()返回True时才发射config.updated_watch_loop的except Exception改为以 warning 级别记录(新增 i18n 键core.config.watcher_error,五语言同步)
修复日期: 2026/07/31
回归测试: tests/unit/test_unit_config.py → test_malformed_toml_preserves_last_valid_cache、test_permission_denied_logs_clear_message(更新为验证保留缓存 + 返回 False)
严重性: 🟡 中等
类型: 配置系统
[BUG-030] 配置 watcher 竞态导致 setConfig 延迟写入静默丢数据
问题: 多个用户报告使用 config.setConfig(key, value)(默认 immediate=False)后,自己的模块配置未写入 config.toml,而其它模块的配置正常。设置 immediate=True(强制刷盘)可规避。表现为:运行期写入的配置在下次重启后丢失,启动期模板生成的配置保留。
原因: 两个叠加缺陷:
- 逻辑缺陷:
_watch_loop在_check_file_change()返回True时无条件_dirty_keys.clear()丢弃所有待写键。但_check_file_change()仅用!=对比 mtime,框架自身的_flush_config写盘也会改变 mtime——虽然_flush_config在写盘后更新_config_mtime,但 watcher 线程在文件写入与 mtime 赋值之间(以及粗粒度文件系统上)仍可能观测到 mtime 差值,误判为"外部修改"并清空全部待写键。 - 线程缺陷:
_watch_loop操作_write_timer/_dirty_keys时未持有_lock,与setConfig(持锁写_dirty_keys)、_schedule_write(持锁写_write_timer)存在数据竞争。
根因链路:
模塊A setConfig(immediate=True) → flush 寫盤,mtime 變化
→ 用戶模塊 setConfig(immediate=False) → 進入 _dirty_keys,5s 後刷盤
→ watcher 輪詢,_check_file_change 觀測到先前自身寫入的 mtime 差值
→ _dirty_keys.clear() → 用戶模塊的待寫鍵被靜默丟棄
→ 重啟後配置缺失
影响版本: 2.6.0 - 2.7.0
修复版本: 2.7.1
修复内容:
- 新增
_last_self_write_mtime字段,_flush_config写盘后同步记录;_check_file_change在 mtime 变化时先对比该值,匹配则判定为自身写入返回False _watch_loop整段持_lock;真正外部修改时保留_dirty_keys(merge 语义),下次 flush 与外部内容合并(脏键优先),不再clear()getConfig/_check_cache_validity路径不受影响(其 reload 本就不清脏键)
修复日期: 2026/08/06
回归测试: tests/unit/test_unit_config.py → test_self_write_not_detected_as_external、test_external_change_preserves_dirty_keys、test_flush_merges_dirty_with_external
严重性: 🔴 严重
类型: 配置系统
[BUG-032] 配置延迟刷盘期间「写后立读」读到旧值
问题: config.setConfig()(默认 immediate=False 延迟约 5 秒刷盘)写入点分键后,立即读取其父级/祖先节点(如 set_erispulse_section("scope.actions.MyModule", {...}) 后调用 get_erispulse_config())返回的是旧值,写入的子键"消失",直到刷盘后才可见。作用域配置热更新等"写-读-写"场景受影响(2.8.0 测试插件 /t_section 用例暴露)。
原因: setConfig 将点分键以扁平形式存入待写队列 _dirty_keys,仅 getConfig 的精确键查询命中待写队列;树形路径查询(getConfig("ErisPulse.scope"))只走缓存树,不叠加待写值——延迟刷盘(_flush_config 才将脏键合并进缓存并清队列)期间形成读-你-写断层。
影响版本: 2.6.0 - 2.8.0-dev.1
修复版本: 2.8.0-dev.1
修复内容: getConfig 引入待写叠加语义——① 精确命中待写键直接返回(原有行为不变);② 待写键是查询键的祖先 → 取最长待写祖先,在其值子树内解析剩余路径;③ 待写键是查询键的后代 → 构建叠加子树(_dirty_overlay)与缓存子树深合并(_deep_merge,override 优先,不修改原缓存对象)。无待写键时走原快路径,零额外开销。
修复日期: 2026/09/04
回归测试: tests/unit/test_unit_config.py → test_get_config_overlays_dirty_descendant、test_get_config_overlay_merges_with_cache_siblings、test_get_config_overlay_new_branch、test_get_config_dirty_ancestor_query、test_get_config_dirty_exact_key_still_wins
严重性: 🟡 中等
类型: 配置系统
[BUG-033] wait_reply 挂起的回复被高优先级处理器饿死
问题: 模块调用 wait_reply() 等待用户回复期间,若该回复消息被更高优先级的事件处理器认领(mark_processed()),命令分发器在入口看到 _processed 标记后直接返回,排在分发器末尾的回复匹配逻辑永远执行不到——等待方收不到回复,只能干等到超时返回 None。典型触发场景:使用了高优先级消息处理器(记录/审计/拦截类)的机器人,所有对话式交互随机性失效。
原因: 回复匹配 _check_pending_reply 挂在 _handle_message 末尾(命令未匹配时才执行),而 _processed 检查在其之前——认领检查与回复命中判定的顺序颠倒。交互等待是框架级的会话延续机制而非竞争处理器,不应受其他处理器的认领影响。
影响版本: 2.2.0-dev.0 - 2.8.0-dev.1
修复版本: 2.8.0-dev.2
修复内容: 回复命中判定提前至 _handle_message 入口(_processed 检查之前、仅 message 事件):先尝试完成挂起的对话,命中后事件被标记已处理、下方检查自然短路;未命中则继续原有命令匹配流程。同时判定链委托新的交互会话管理器(Core/Event/interaction.py),顺带获得按归属取消与权限复查能力。
修复日期: 2026/09/08
回归测试: tests/unit/test_unit_interaction.py(TestRegisterResolve 命中/未命中/认领标记)、tests/unit/test_unit_event.py(wait_reply 全链路)
严重性: 🟡 中等
类型: 事件系统 / 命令系统
[BUG-034] 作用域 persist=False 运行时绑定被任意后续配置写入静默冲掉
问题: scope.set_module(..., persist=False) 等运行时写入仅修改内存 self._data;但 scope 订阅了 config.set / config.updated 事件,任意一处代码写配置(如某模块加载时写自己的默认配置)都会触发 scope 从配置文件整体重建配置树,此前所有运行时绑定静默丢失(判定回退为默认放行),且无任何日志提示。依赖运行时绑定的场景(Dashboard"仅运行时"开关、模块运行期动态禁用)在无关模块写配置后行为回退。
原因: 根因链路:scope.set/delete(persist=False) 仅写内存(Core/scope.py)→ 任意 setConfig 触发 config.set 事件 → _on_config_updated 无条件 _load_config() → _apply_tree() 以 self._data = {...} 整体替换 → 不在配置文件中的运行时绑定被丢弃。
影响版本: 2.8.0-dev.1 - 2.8.0-dev.2
修复版本: 2.8.0-dev.2
修复内容: 引入运行时覆盖层 _runtime_overrides(含删除哨兵):persist=False 写/删记录进覆盖层,_apply_tree() 重建持久层后按写入顺序重放,运行时规则在任意配置写入后保持有效;persist=True 写/删清除对应覆盖记录(用户持久化语义优先);config.set 按事件 key 精确过滤、config.updated 对比新旧 scope 节,仅 scope 实际变化时才重建(附带避免无关写入冲刷判定 LRU 缓存);新增 unregister_by_owner() 供模块卸载时随调用方兜底清理。Core.Event.overrides 的 persist=False 运行时覆写存在同类问题,同步以覆盖层架构修复。
修复日期: 2026/09/09
复现步骤: ① scope.set_module("testplat", blocked=["TestB"], persist=False) → 判定 False;② 任意模块执行 config.setConfig("HelpModule", {...}) → 触发 scope 重建;③ scope.is_allowed("testplat", None, "TestB") 返回 True(预期仍为 False)。
关联: Issue #432
回归测试: tests/unit/test_unit_scope.py::TestRuntimeOverrideSurvival(无关写入存活/树重建重放/删除哨兵/持久化清除/精确失效/owner 清理)
严重性: 🟡 中等 类型: 配置系统 / 运行时
[BUG-035] 配置面板 select 选项与字典字段渲染为 [object Object]
问题: WebUI 配置面板中,select 字段的选项下拉显示 [object Object](如动态生成的配色风格选项);未声明控件类型的 dict 字段(如 stalker_mode、knowledge_base 等嵌套配置段)在文本框中显示 [object Object],无法正常查看与编辑。
原因: 两处独立缺陷:① 框架 i18n 解析器 _resolve_i18n_text 仅还原带 i18n 键的字典,选项标签为仅含 default 的字典(无 i18n 键的动态文本)时原样透传,前端 esc(label) 字符串强转得到 [object Object];② Dashboard 渲染分支只对 array 类型做 JSON textarea,dict 值落入纯文本输入分支被 String() 强转。此外模块若将 _schema_meta 误声明为普通 dataclass 字段(缺 ClassVar 注解),会作为配置字段进入 schema 加剧混乱。
影响版本: 2.7.0 - 2.8.0-dev.2
修复版本: 2.8.0-dev.2
修复内容: ① _resolve_i18n_text 支持仅含 default 的字典还原为文本;② 框架 schema/模板/默认值/填充/校验五处一律排除下划线前缀字段(误声明无害化);③ Dashboard select 选项 label 对象兜底解析(default 优先)、dict/table 字段渲染为 JSON textarea(保存路径按 tp=object JSON.parse 回写,完整往返)。
修复日期: 2026/09/09
回归测试: tests/unit/test_unit_config.py::TestResolveI18nDefaultOnlyDict、TestSchemaUnderscoreFieldExclusion
严重性: 🟡 中等 类型: 配置系统
[BUG-036] 多实例共享配置目录时配置写入偶发失败(ENOENT)
问题: Docker 部署場景下(多容器掛載同一宿主機配置目錄),日誌偶發連續兩條 Failed to write configuration file ... [Errno 2] No such file or directory: '...config.toml.tmp' -> '...config.toml'。該次配置寫入被丟棄(舊配置完整保留,未觀察到配置丟失),功能不受影響,但告警反覆出現干擾排查,且待寫入的配置項需等待下次寫入才能落盤。
原因: 根因鏈路:_flush_config / setConfigTemplate 使用固定名臨時文件 config.toml.tmp 承載新內容,write() 後不 fsync 直接 rename()。兩個 ErisPulse 實例共享同一配置目錄時,B 實例 open("w") 可能 truncate A 實例正在寫的臨時文件 → A rename 時目標已被 B 取走或內容被截斷,報 ENOENT(即用戶日誌中的連續兩條錯誤)。已報告案例中僅表現為寫入失敗告警(舊配置保留);若交錯時序更極端,rename 可能輸出空/半截的 config.toml(屬潛在風險,尚未在真實環境爆發)。單實例場景下 ext4 延遲分配同樣存在「rename 元數據先於數據塊落盤」的崩潰窗口(SIGKILL / 斷電)。_file_lock 為進程內 threading.RLock,對跨進程/跨容器寫入無約束。
影響版本: 2.2.0-dev.0 - 2.8.0
修復版本: 2.8.1
修復內容: 配置寫入統一收斂到 _atomic_write_text():同目錄 mkstemp 生成進程唯一臨時文件(消除固定名爭搶,多實例下退化為 last-writer-wins,不再 ENOENT)→ 寫畢 flush + fsync 強制數據落盤(消除「rename 已生效、數據未落盤」窗口)→ os.replace 原子替換目標(POSIX/Windows 均原子,任一時刻磁盤上要么完整舊內容、要么完整新內容);POSIX 下額外 fsync 配置目錄。_flush_config、setConfigTemplate、根目錄配置遷移三處寫入點全部切換;異常路徑清理邏輯隨唯一臨時文件名重構。新增多實例檢測:啟動時 advisory lock(POSIX flock / Windows msvcrt.locking)獨占持有配置目錄鎖文件 .erispulse_config.lock,被占用即輸出 i18n 告警(不阻塞啟動),鎖由 OS 在進程退出時自動釋放、無幽靈鎖。
修復日期: 2026/09/13
復現步驟: ① 兩個容器掛載同一宿主機 config/ 目錄並同時運行 ErisPulse;② 任一實例觸發配置寫入(如模塊註冊預設配置);③ 觀察日誌出現 ENOENT 寫失敗告警,本次寫入被丟棄(舊配置保留)。
關聯: 用戶報告(1Panel 容器 ×2)
回歸測試: tests/unit/test_unit_config_atomic_write.py(內容完整寫入/無臨時文件殘留/寫失敗保原文件/雙實例並發寫入文件始終合法/鎖文件創建/多實例告警/遷移原子寫入)
嚴重性: 🟢 輕微
類型: 配置系統
[BUG-037] 空格子命令名註冊後永遠無法被觸發
問題: 以空格分隔的多 token 命令名註冊子命令(如 @command("admin add"))後,命令可正常註冊並出現在幫助列表中,但用戶發送 /admin add 時機器人永遠無回應——輸入被父 token 命令匹配為 admin + 參數 ["add"];若父 token 也未註冊則完全無回應。僅當使用點分命名(admin.reload,整體為單 token)時可規避。
原因: 根因鏈路:CommandHandler.__call__ 將任意命令名(含空格形式)原樣存入平鋪的 self.commands 字典 → 分發階段 _try_execute_command 僅取消息首 token 匹配(cmd_name = parts[0])→ 多 token 命令名作為字典鍵永遠查不到。註冊與匹配兩階段對命令名的空間假設不一致,且無任何註冊期告警(靜默失效)。
影響版本: 引入命令系統起 - 2.8.0
修復版本: 2.8.1
修復內容: 匹配層改為最長前綴匹配:從最長候選(" ".join(parts[:n]),n 上限為已註冊命令名/別名的最大 token 數緩存 _max_name_tokens)逐級降級嘗試,命中即以剩餘 token 為參數執行,下游作用域/ACL/覆寫/master/權限鏈對命令全名自然生效。配套語義:父子並存時未註冊的子命令輸入回落父命令(歷史行為不變);子命令未聲明 permission 時沿父鏈繼承最近聲明權限的祖先命令(保護父命令即保護其下全部子命令);僅註冊單 token 命令時首輪即命中,分發開銷與原先一致。unregister / unregister_by_owner / 全量清理同步維護 token 數緩存。
修復日期: 2026/09/13
復現步驟: ① 模塊內 @command("admin add") 註冊;② 發送 /admin add x;③ 修復前無回應(或被同時註冊的 /admin 以參數形式接住),修復後 admin add 觸發且 get_command_args() 為 ["x"]。
回歸測試: tests/unit/test_unit_command_subcommand.py(最長前綴匹配/僅子命令觸發/三級嵌套/大小寫敏感兩模式/單與多 token 別名/事件載荷全名/生命周期鉤子全名/權限繼承六例/ACL glob 全名/master/註銷回落與緩存重算)
嚴重性: 🟡 中等
類型: 事件系統
[BUG-038] persist=False 運行時綁定被持久化寫入順帶落盤,模組卸載後從磁盤"復活"
問題: 以 scope.set(path, value, persist=False) 寫入的運行時綁定(文檔承諾不落盤、進程重啟即失效、模組卸載時清理)會隨任意一次無關的 persist=True 寫入(預設值,如模組 set_module / WebUI 保存配置)被差量寫入用戶 config.toml;此後即使模組卸載註銷了運行時綁定,下次配置重載時該綁定仍從磁盤"復活"繼續生效、進程重啟後依然存在——與"運行時綁定不落盤"的語義契約相反,且極難排查。
原因: 根因鏈路:ScopeManager.set() 先把值寫入內存配置樹 _data(運行時綁定同樣直接寫入 _data)→ 持久化分支對整棵 _data 做深拷貝快照提交給 update_erispulse_config 差量落盤 → 快照中混入了 persist=False 綁定的值。delete(persist=True) 同源:把 _data 的活引用(parent 節點)交給延遲寫臟隊列,延遲刷盤期間對該節點下兄弟鍵的運行時修改會被一並落盤,且活引用在臟隊列滯留期間存在跨寫污染窗口。
影響版本: 2.8.0-dev.2 - 2.9.0-dev.0
修復版本: 2.9.0-dev.1
修復內容: 引入持久化基線 _persisted_tree(配置樹重建時以磁盤加載、校驗後的樹刷新)作為磁盤真相鏡像:set(persist=True) 只在基線上應用本次變更後提交差量,運行時綁定永不進入持久化內容;"寫後立讀"改用內存最終態快照直接恢復,不再經 _apply_tree 重建以免污染基線。delete(persist=True) 同口徑:在基線上應用刪除並把基線子樹的深拷貝交給持久化層;set_action 整體替換語義先按同口徑刪除再寫入,舊規則鍵不殘留在持久化內容中。
修復日期: 2026/09/27
復現步驟: ① 模組內執行 scope.set("bots.p.debug_mode", {"blocked": ["X"]}, persist=False);② 觸發任意持久化寫入(如另一模組調用 set_module);③ 打開 config/config.toml,可見 debug_mode 已被寫入(修復前);④ 卸載該模組(運行時綁定被註銷)後觸發配置重載,scope.get("bots.p.debug_mode") 仍返回綁定值。
回歸測試: tests/unit/test_unit_scope.py::TestPersistBaseline(運行時綁定不隨無關持久化寫入落盤 / 卸載註銷後配置重載不復活 / delete 提交基線子樹不帶運行時兄弟鍵 / set_action 替換不留殘留鍵 / cache_size 配置生效)
嚴重性: 🔴 嚴重
類型: 配置系統
[BUG-039] 整節寫與點分寫並存時讀寫不一致(點分覆寫丟失)
問題: 延遲刷盤窗口內,同節並存整節寫(setConfig("Mod", {...}),如 BaseModule.cfg 寫回)與點分寫(setConfig("Mod.key", v),如配置熱更 / 測試工具覆寫)時有兩個變體:變體 A(讀路徑)——getConfig("Mod") 整節讀取返回舊整節待寫快照,看不到更晚的點分覆寫(點分讀取路徑正常);變體 B(寫路徑,更重)——點分寫在前、整節寫在後(測試工具注入覆寫 → 模組 self.cfg = ... 寫回的常見時序)時,flush 按插入序應用臟鍵,整節寫整體替換該節,點分覆寫在磁盤上永久丟失。生產環境"配置熱更 + 模組運行時寫回"組合即可觸發,與測試環境無關(ErisPulse-DailyCard 測試反饋暴露)。
原因: getConfig 第①段(精確命中 _dirty_keys)提前 return self._dirty_keys[key],完全繞過第④段 _dirty_overlay 後代覆寫;_flush_config 按 _dirty_keys 插入序應用臟鍵,整節寫恰好排在點分寫之後時點分值被整體替換。另:第①段返回臟窗口原對象引用,調用方原地修改返回 dict 會直接改動待落盤狀態。
影響版本: 2.6.0 - 2.9.0-dev.1
修復版本: 2.9.0-dev.1
修復內容: 脏窗口內統一為特異性優先語義——點分(更具體)待寫值優先於整節(較寬)待寫值,讀路徑與落盤路徑同口徑:① getConfig 精確命中待寫鍵後仍疊加 _dirty_overlay 後代待寫值(非 dict 值返回疊加子樹,與③+④標量邊角同口徑);② _flush_config 脏鍵按路徑深度排序應用(整節/祖先先、點分/後代後,穩定排序保持同深度寫入時序)。代價:同一臟窗口內(約 5 秒)整節寫回無法覆蓋仍掛起的點分寫——讀路徑修復後讀-改-寫自然攜帶點分值,實際影響面極小。③ 涉及待寫值的 getConfig 返回(精確命中 / 祖先子樹 / 疊加合併)改為 copy.deepcopy 隔離拷貝,不再泄漏臟窗口內部引用;無臟鍵快路徑與純緩存讀行為不變。
修復日期: 2026/09/28
復現步驟: ① setConfig("FB.y", 1);② setConfig("FB", {"z": 2});③ force_save()——修復前磁盤只剩 [FB] z = 2(y 丟失),修復後 {"y": 1, "z": 2};變體 A:步驟①②後不落盤直接 getConfig("FB")——修復前不含 y,修復後可見。
回歸測試: tests/unit/test_unit_config.py::TestSectionAndDottedDirtyConsistency(變體 A 兩種插入序 / 變體 B 落盤與順序無關 / 三層混合存活 / 隔離拷貝 / 祖先子樹隔離)
嚴重性: 🟡 中等
類型: 配置系統