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: pass不记录任何日志。
根因链路:
用户保存到一半 → 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 落盘与顺序无关 / 三层混合存活 / 隔离拷贝 / 祖先子树隔离)
严重性: 🟡 中等
类型: 配置系统