Skip to content

fix: 修复 WebUI 第二事件循环引发的三处并发硬伤,并修复 account_id 测试回归 - #1908

Open
s1f102500012 wants to merge 4 commits into
Mai-with-u:devfrom
s1f102500012:fix/webui-cross-loop-hardening
Open

s1f102500012 wants to merge 4 commits into
Mai-with-u:devfrom
s1f102500012:fix/webui-cross-loop-hardening

Conversation

@s1f102500012

@s1f102500012 s1f102500012 commented Jul 14, 2026

Copy link
Copy Markdown

请填写以下内容

    • main 分支 禁止修改,请确认本次提交的分支 不是 main 分支
    • 我确认我阅读了贡献指南
    • 本次更新类型为:BUG修复
    • 本次更新类型为:功能新增
    • 本次更新是否经过测试
    • 如果本次修改涉及 src/A_memorix,我确认已阅读 src/A_memorix/MODIFICATION_POLICY.md,不涉及则无需勾选(本次不涉及)
  1. 请填写破坏性更新的具体内容(如有):
    无。对外接口与数据格式均不变。行为变化仅两点且都是收敛竞态:WebUI 触发的配置热重载/聊天流删除从"立即返回但留有竞态"变为等待主循环完成(毫秒级延迟);出站消息落库从事件循环内同步写改为与入站消息一致的线程池串行写。
  2. 请简要说明本次更新的内容和目的:
    修复 WebUI 第二事件循环跨线程触碰主循环对象引发的三处并发 bug,并修复 ada7c16 引入的 6 个测试回归:
  • 配置热重载的 asyncio.Lock 被主循环与 WebUI 循环跨 loop 持有,互斥完全失效(跨 loop 争用还会抛 RuntimeError),两次重载可交错执行;现经 run_on_main_loop 统一投递到主循环串行执行。
  • WebUI 删除聊天流直接跨线程 pop 运行期对象且不 stop(),留下仍在运行的幽灵 runtime(任务泄漏,可能对已删除会话继续调 LLM),并与定期保存的遍历竞态;现复用 HeartflowManager 的淘汰路径在主循环上停止并释放。
  • 出站消息落库绕过 _DB_WRITE_THREAD_LOCK 且在事件循环内同步写 SQLite,DB 争用时主循环被顶住最长 1 秒、超时则消息已发出但未入库;现走新增的 MessageUtils.store_sent_message_to_db_async(持锁 + to_thread)。
  • ada7c16 改为直接属性访问并新增 account_id 响应字段后测试桩未同步,pytests/webui 6 个用例回归失败;已补齐测试桩。

测试:pytests/webui 从 8 failed / 186 passed 恢复到 2 failed / 192 passed(剩余 2 个失败在父提交上同样存在,为预存问题);pytests/config_test(含热重载专项)、test_chat_routespytests/message_test 与基线逐项对照零新增失败;改动文件 ruff check 全过。

其他信息

  • 关联 Issue:无
  • 截图/GIF:无(纯后端并发修复)
  • 附加信息:

背景

WebUI 运行在独立线程的第二事件循环上。排查发现有三条路径直接跨循环/跨线程触碰主循环持有的对象,会导致互斥失效、后台任务泄漏和消息落库丢失;另外 ada7c16 引入了 6 个测试回归。本 PR 共 4 个提交,逐项修复。

修复内容

1. 配置热重载跨事件循环锁失效(fix(config)

ConfigManager.reload_configasyncio.Lock 同时被主循环(文件监视器 _handle_file_changes)和 WebUI 循环(webui/routers/chat/routes.py 5 处、webui/routers/config.py 1 处)持有。asyncio.Lock 只能互斥同一 loop 内的协程:跨 loop 争用时要么抛 RuntimeError,要么完全失去互斥——两次 reload_config 可交错执行(配置对象换入、reload_revision 自增、回调遍历全部竞态)。且重载回调在调用方所在线程执行,WebUI 触发时会对主循环持有的 asyncio.Event 直接 set()(如 emoji_manager.reload_runtime_config),跨线程唤醒不可靠。

修法:加锁主体拆为 _reload_config_on_main_loopreload_config 经现成的 run_on_main_loop 统一投递到主循环(与 webui/routers/system.pyplugin/config_routes.py 现有惯例一致)。主循环调用方 run_on_main_loop 会直接 await,行为不变;所有调用点无需改动。

2. WebUI 删除聊天流导致 runtime 任务泄漏(fix(webui)

_release_deleted_chat_runtime 在 WebUI 线程直接 pop 主循环的 core_chat_manager.sessionsheartflow_manager.heartflow_chat_list

  • 被弹出的 MaisakaHeartFlowChatting 没有走 stop()(对比正常 LRU 淘汰路径 _evict_chat),内部循环任务、计时器继续在主循环运行成幽灵 runtime,仍可能对已删除会话触发 LLM 调用;
  • _chat_last_active_at / _chat_create_locks 条目被遗留;
  • 跨线程改 dict 与主循环写入、定期保存的遍历竞态,可致 dictionary changed size during iteration 使当轮保存整体失败。

修法HeartflowManager 新增 release_chat() 复用 _evict_chat 的完整停止逻辑;_release_deleted_chat_runtime 改为 async,经 run_on_main_loop 在主循环执行释放。

备注:save_all_sessionsto_thread 里遍历实时 dict 与主循环写入之间仍有预先存在的竞态窗口(会话创建同样触发),不在本 PR 范围内,可另行讨论是否遍历前先做快照。

3. 出站消息落库绕过串行写锁(fix(sender)

uni_message_sender._send_message 存储已发送消息时直接在 async 上下文同步 get_db_session() 写库,绕过了 utils_message.py_DB_WRITE_THREAD_LOCK 的进程级写串行化约束(该锁的注释明确"所有调用路径……不存在绕过路径")。后果:主事件循环被 SQLite busy_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.py 5 处 SimpleNamespace 桩缺 account_idAttributeError 500;
  • test_jargon_routes.py 1 处精确 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.pypytests/message_test/ 与基线逐项对照,零新增失败(基线上 test_startup_bindings 4 例与 session_message_test 2 例为本地环境预存失败);
  • 五个改动模块导入烟测通过。

影响面

  • 主循环调用 reload_config / 删除聊天流的行为不变(run_on_main_loop 在主循环上等价于直接 await);
  • WebUI 触发的配置重载、聊天流删除从"立即返回但留下竞态"变为"等待主循环完成",接口语义更严格,延迟增加通常在毫秒级;
  • 出站消息落库从事件循环内同步写改为线程池串行写,与入站消息 store_message_to_db_async 路径完全一致。

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 字段。
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

本次变更将发送消息落库改为异步串行写入,并将配置热重载和 WebUI 聊天删除释放逻辑调度到主事件循环,同时更新相关测试中的会话字段。

Changes

异步执行与运行时资源管理

Layer / File(s) Summary
发送消息异步落库
src/chat/message_receive/uni_message_sender.py, src/common/utils/utils_message.py
发送成功后的消息通过带进程级写锁的异步落库方法保存,移除同步数据库会话写入路径。
配置热重载主循环调度
src/config/config.py
有效配置变更通过 run_on_main_loop 调度,并由新的主循环方法执行配置重载、版本更新和回调。
聊天删除运行时释放
src/chat/heart_flow/heartflow_manager.py, src/webui/routers/chat/routes.py, pytests/webui/*
新增 release_chat 封装心流停止与移除;聊天删除等待主循环中的运行时释放完成,相关测试补齐会话字段并校验 account_id

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()
Loading

Possibly related PRs

Suggested reviewers: sengokucola, a-dawn

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed 标题准确概括了 WebUI 并发修复和 account_id 测试回归修复,且简洁清晰。
Description check ✅ Passed 描述基本覆盖模板要求,包含勾选项、更新内容、目的、测试结果与其他信息。
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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_threadpoolasyncio.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

📥 Commits

Reviewing files that changed from the base of the PR and between ada7c16 and d3d1917.

📒 Files selected for processing (7)
  • pytests/webui/test_jargon_routes.py
  • pytests/webui/test_memory_routes.py
  • src/chat/heart_flow/heartflow_manager.py
  • src/chat/message_receive/uni_message_sender.py
  • src/common/utils/utils_message.py
  • src/config/config.py
  • src/webui/routers/chat/routes.py

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant