fix: 修复 WebUI 第二事件循环引发的三处并发硬伤,并修复 account_id 测试回归 - #1908
s1f102500012 wants to merge 4 commits into
Conversation
WebUI 运行在独立线程的第二事件循环上,chat/config 路由触发的 reload_config 与主循环文件监视器触发的 reload_config 共用同一把 asyncio.Lock。asyncio.Lock 只能互斥同一 loop 内的协程:跨 loop 争用时 要么抛 RuntimeError,要么完全失去互斥,两次重载可交错执行(配置对象 换入、reload_revision 自增、回调遍历全部竞态);且重载回调会在 WebUI 线程上对主循环持有的 asyncio.Event 直接 set(),跨线程唤醒不可靠。 现将 reload_config 的加锁主体经 run_on_main_loop 统一投递到主循环执行 (与 webui/routers 现有惯例一致),主循环调用方行为不变,所有调用点 (含 A_memorix host_service)无需改动即恢复串行化。
WebUI 删除聊天流的 _release_deleted_chat_runtime 原先在 WebUI 线程直接 pop 主循环持有的 core_chat_manager.sessions 与 heartflow_manager.heartflow_chat_list: - 被弹出的 MaisakaHeartFlowChatting 没有走 stop(),内部循环任务与 计时器继续在主循环运行,成为无人管理的幽灵 runtime,仍可能对已 删除会话触发 LLM 调用; - _chat_last_active_at / _chat_create_locks 中的条目同时被遗留; - 跨线程修改 dict 与主循环写入、定期保存的遍历三方竞态,可导致 "dictionary changed size during iteration" 使当轮保存整体失败。 现为 HeartflowManager 增加 release_chat(),复用与 LRU 淘汰相同的 _evict_chat 停止逻辑(含锁与活跃时间清理),并将整个释放过程经 run_on_main_loop 投递到主循环执行。
uni_message_sender 存储已发送消息时直接在 async 上下文里同步 get_db_session 写库,绕过了 utils_message 中 _DB_WRITE_THREAD_LOCK 的进程级写串行化约束(该锁注释明确要求所有消息写入路径不得绕过): - 同步写库在事件循环上执行,SQLite 写锁争用时主循环被 busy_timeout 顶住最长 1 秒; - 超时抛 OperationalError 时消息已发出但未入库,回复引用、上下文 与学习数据缺失该记录。 新增 MessageUtils.store_sent_message_to_db(_async),持锁 + to_thread, 行为与原逻辑一致(fill_reply_frequency + 落库,不做图片组件落盘), 发送侧改为调用该路径。
ada7c16 将 _timeline_chat_from_session 改为直接属性访问并为 ChatInfoResponse 新增 account_id 后,memory timeline 的 5 个 SimpleNamespace 测试桩与 jargon chats 的 1 个精确比对断言未同步, CI 上 6 个用例回归失败。为桩对象补充 account_id=None,并在期望 响应中加入 account_id 字段。
Walkthrough本次变更将发送消息落库改为异步串行写入,并将配置热重载和 WebUI 聊天删除释放逻辑调度到主事件循环,同时更新相关测试中的会话字段。 Changes异步执行与运行时资源管理
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant WebUI
participant DeleteScope
participant MainLoop
participant HeartflowManager
WebUI->>DeleteScope: await delete_chat_session_scope(session_id)
DeleteScope->>MainLoop: run_on_main_loop(_stop_and_release)
MainLoop->>HeartflowManager: release_chat(session_id, reason="webui_delete")
HeartflowManager->>HeartflowManager: _evict_chat()
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/webui/routers/chat/routes.py (1)
1117-1117: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win建议将同步的数据库操作放入线程池执行,避免阻塞事件循环。
_delete_chat_session_scope现在已被重构为异步函数(async def),但其内部(Line 1120 起)直接执行了大量同步的数据库读写操作(如with get_db_session()及session.exec等)。在 FastAPI 等异步框架中,直接在async函数内执行耗时的同步 I/O 会阻塞整个事件循环,从而影响 WebUI 其他并发请求的处理。根据路径指令,需重点关注“异步代码的正确性(async/await 使用是否合理)”。作为后续优化,建议将内部纯同步的数据库操作提取到一个普通的
def函数中,并通过 FastAPI 的run_in_threadpool或asyncio.to_thread来异步调用它。🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/webui/routers/chat/routes.py` at line 1117, Extract the synchronous database work currently inside _delete_chat_session_scope, including get_db_session and session.exec operations, into a regular synchronous helper function, then invoke that helper via FastAPI run_in_threadpool or asyncio.to_thread and await its result. Preserve the existing return value and deletion behavior while keeping blocking database I/O off the event loop.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/webui/routers/chat/routes.py`:
- Line 1117: Extract the synchronous database work currently inside
_delete_chat_session_scope, including get_db_session and session.exec
operations, into a regular synchronous helper function, then invoke that helper
via FastAPI run_in_threadpool or asyncio.to_thread and await its result.
Preserve the existing return value and deletion behavior while keeping blocking
database I/O off the event loop.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 43f06bb4-7dbf-40ea-9c65-7fd56c1181dc
📒 Files selected for processing (7)
pytests/webui/test_jargon_routes.pypytests/webui/test_memory_routes.pysrc/chat/heart_flow/heartflow_manager.pysrc/chat/message_receive/uni_message_sender.pysrc/common/utils/utils_message.pysrc/config/config.pysrc/webui/routers/chat/routes.py
请填写以下内容
main分支 禁止修改,请确认本次提交的分支 不是main分支src/A_memorix,我确认已阅读src/A_memorix/MODIFICATION_POLICY.md,不涉及则无需勾选(本次不涉及)无。对外接口与数据格式均不变。行为变化仅两点且都是收敛竞态:WebUI 触发的配置热重载/聊天流删除从"立即返回但留有竞态"变为等待主循环完成(毫秒级延迟);出站消息落库从事件循环内同步写改为与入站消息一致的线程池串行写。
修复 WebUI 第二事件循环跨线程触碰主循环对象引发的三处并发 bug,并修复 ada7c16 引入的 6 个测试回归:
asyncio.Lock被主循环与 WebUI 循环跨 loop 持有,互斥完全失效(跨 loop 争用还会抛RuntimeError),两次重载可交错执行;现经run_on_main_loop统一投递到主循环串行执行。pop运行期对象且不stop(),留下仍在运行的幽灵 runtime(任务泄漏,可能对已删除会话继续调 LLM),并与定期保存的遍历竞态;现复用HeartflowManager的淘汰路径在主循环上停止并释放。_DB_WRITE_THREAD_LOCK且在事件循环内同步写 SQLite,DB 争用时主循环被顶住最长 1 秒、超时则消息已发出但未入库;现走新增的MessageUtils.store_sent_message_to_db_async(持锁 +to_thread)。account_id响应字段后测试桩未同步,pytests/webui6 个用例回归失败;已补齐测试桩。测试:
pytests/webui从 8 failed / 186 passed 恢复到 2 failed / 192 passed(剩余 2 个失败在父提交上同样存在,为预存问题);pytests/config_test(含热重载专项)、test_chat_routes、pytests/message_test与基线逐项对照零新增失败;改动文件ruff check全过。其他信息
背景
WebUI 运行在独立线程的第二事件循环上。排查发现有三条路径直接跨循环/跨线程触碰主循环持有的对象,会导致互斥失效、后台任务泄漏和消息落库丢失;另外 ada7c16 引入了 6 个测试回归。本 PR 共 4 个提交,逐项修复。
修复内容
1. 配置热重载跨事件循环锁失效(
fix(config))ConfigManager.reload_config的asyncio.Lock同时被主循环(文件监视器_handle_file_changes)和 WebUI 循环(webui/routers/chat/routes.py5 处、webui/routers/config.py1 处)持有。asyncio.Lock只能互斥同一 loop 内的协程:跨 loop 争用时要么抛RuntimeError,要么完全失去互斥——两次reload_config可交错执行(配置对象换入、reload_revision自增、回调遍历全部竞态)。且重载回调在调用方所在线程执行,WebUI 触发时会对主循环持有的asyncio.Event直接set()(如emoji_manager.reload_runtime_config),跨线程唤醒不可靠。修法:加锁主体拆为
_reload_config_on_main_loop,reload_config经现成的run_on_main_loop统一投递到主循环(与webui/routers/system.py、plugin/config_routes.py现有惯例一致)。主循环调用方run_on_main_loop会直接 await,行为不变;所有调用点无需改动。2. WebUI 删除聊天流导致 runtime 任务泄漏(
fix(webui))_release_deleted_chat_runtime在 WebUI 线程直接pop主循环的core_chat_manager.sessions与heartflow_manager.heartflow_chat_list:MaisakaHeartFlowChatting没有走stop()(对比正常 LRU 淘汰路径_evict_chat),内部循环任务、计时器继续在主循环运行成幽灵 runtime,仍可能对已删除会话触发 LLM 调用;_chat_last_active_at/_chat_create_locks条目被遗留;dictionary changed size during iteration使当轮保存整体失败。修法:
HeartflowManager新增release_chat()复用_evict_chat的完整停止逻辑;_release_deleted_chat_runtime改为 async,经run_on_main_loop在主循环执行释放。3. 出站消息落库绕过串行写锁(
fix(sender))uni_message_sender._send_message存储已发送消息时直接在 async 上下文同步get_db_session()写库,绕过了utils_message.py中_DB_WRITE_THREAD_LOCK的进程级写串行化约束(该锁的注释明确"所有调用路径……不存在绕过路径")。后果:主事件循环被 SQLitebusy_timeout顶住最长 1 秒;争用超时抛OperationalError时消息已发出但未入库,回复引用、上下文与学习数据缺失该记录。修法:新增
MessageUtils.store_sent_message_to_db(_async)(持锁 +to_thread,行为与原逻辑逐句一致:fill_reply_frequency+ 落库,不引入图片组件落盘),发送侧改为调用该路径。4. ada7c16 的 6 个测试回归(
test(webui))ada7c16 将
_timeline_chat_from_session改为直接属性访问并为ChatInfoResponse新增account_id后,测试桩未同步:test_memory_routes.py5 处SimpleNamespace桩缺account_id→AttributeError500;test_jargon_routes.py1 处精确 dict 比对缺account_id键。按仓库规范(生产代码避免
getattr兜底)修的是测试桩而不是回退生产代码。验证
ruff check全过(改动文件),ruff format差异仅为改动前就未格式化的旧代码,未触碰;pytests/webui/:修复前 8 failed / 186 passed → 修复后 2 failed / 192 passed,剩余 2 个失败(test_plugin_management_routes安装用例)在父提交上同样失败,为预先存在问题,与本 PR 无关;pytests/config_test/(含test_config_manager_hot_reload.py)、pytests/webui/test_chat_routes.py、pytests/message_test/与基线逐项对照,零新增失败(基线上test_startup_bindings4 例与session_message_test2 例为本地环境预存失败);影响面
reload_config/ 删除聊天流的行为不变(run_on_main_loop在主循环上等价于直接 await);store_message_to_db_async路径完全一致。