From ffcc82b32860030edcb6c79a7d094a4f35cc0e97 Mon Sep 17 00:00:00 2001 From: Natsuki Date: Tue, 14 Jul 2026 17:47:40 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(config):=20=E9=85=8D=E7=BD=AE=E7=83=AD?= =?UTF-8?q?=E9=87=8D=E8=BD=BD=E7=BB=9F=E4=B8=80=E6=8A=95=E9=80=92=E5=88=B0?= =?UTF-8?q?=E4=B8=BB=E5=BE=AA=E7=8E=AF=EF=BC=8C=E4=BF=AE=E5=A4=8D=E8=B7=A8?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=BE=AA=E7=8E=AF=E9=94=81=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)无需改动即恢复串行化。 --- src/config/config.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/config/config.py b/src/config/config.py index 2c0bd10352..7a917d4b25 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -9,6 +9,7 @@ from src.common.i18n import t from src.common.logger import get_logger +from src.common.runtime_loop import run_on_main_loop from src.common.version import read_project_version from .config_base import AttributeData, ConfigBase, Field @@ -427,6 +428,22 @@ async def reload_config(self, changed_scopes: Sequence[str] | None = None) -> bo logger.debug("配置热重载未命中有效范围,已跳过") return True + # WebUI 运行在独立线程的第二事件循环上,也会触发热重载;asyncio.Lock + # 只能互斥同一个 loop 内的协程,跨 loop 争用时要么抛 RuntimeError 要么 + # 完全失去互斥。统一投递到主循环执行,保证重载与回调串行化,且回调 + # (如对主循环 asyncio.Event 的 set)总是在其所属的事件循环上运行。 + return await run_on_main_loop(self._reload_config_on_main_loop(normalized_scopes)) + + async def _reload_config_on_main_loop(self, normalized_scopes: tuple[str, ...]) -> bool: + """在主循环上串行执行配置热重载。 + + Args: + normalized_scopes: 已规范化的配置变更范围。 + + Returns: + bool: 是否重载成功。 + """ + async with self._reload_lock: try: global_config_new = self.global_config From b7c68465608582010933be60decbca7d58d7bc71 Mon Sep 17 00:00:00 2001 From: Natsuki Date: Tue, 14 Jul 2026 17:47:52 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(webui):=20=E5=88=A0=E9=99=A4=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=B5=81=E6=97=B6=E5=9C=A8=E4=B8=BB=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E5=BF=83=E6=B5=81=E5=AE=9E=E4=BE=8B=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=BB=E5=8A=A1=E6=B3=84=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 投递到主循环执行。 --- src/chat/heart_flow/heartflow_manager.py | 8 ++++++++ src/webui/routers/chat/routes.py | 24 +++++++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/chat/heart_flow/heartflow_manager.py b/src/chat/heart_flow/heartflow_manager.py index a31a1215de..6c6d4dedc3 100644 --- a/src/chat/heart_flow/heartflow_manager.py +++ b/src/chat/heart_flow/heartflow_manager.py @@ -91,6 +91,14 @@ async def _evict_chat(self, session_id: str, *, reason: str) -> None: except Exception as exc: logger.warning(f"淘汰心流聊天 {session_id} 失败: {exc}", exc_info=True) + async def release_chat(self, session_id: str, *, reason: str = "released") -> None: + """停止并移除指定会话的心流实例。 + + 供聊天流删除等外部路径复用,与 LRU 淘汰共用同一条 stop 逻辑, + 避免绕过 stop 直接弹出字典导致后台任务泄漏。 + """ + await self._evict_chat(session_id, reason=reason) + def adjust_talk_frequency(self, session_id: str, frequency: float) -> None: """调整指定聊天流的说话频率。""" chat = self.heartflow_chat_list.get(session_id) diff --git a/src/webui/routers/chat/routes.py b/src/webui/routers/chat/routes.py index 1249810cb7..ac17b44dd4 100644 --- a/src/webui/routers/chat/routes.py +++ b/src/webui/routers/chat/routes.py @@ -29,6 +29,7 @@ ToolRecord, ) from src.common.logger import get_logger +from src.common.runtime_loop import run_on_main_loop from src.common.utils.utils_config import ( BehaviorConfigUtils, ChatConfigUtils, @@ -1097,14 +1098,23 @@ def _delete_or_unlink_jargons(session: Any, session_id: str) -> Dict[str, int]: } -def _release_deleted_chat_runtime(session_id: str) -> None: - """移除运行期缓存,避免定时保存把已删除聊天流重新写回数据库。""" +async def _release_deleted_chat_runtime(session_id: str) -> None: + """在主循环上停止心流实例并移除运行期缓存。 - core_chat_manager.sessions.pop(session_id, None) - heartflow_manager.heartflow_chat_list.pop(session_id, None) + 一是避免定时保存把已删除聊天流重新写回数据库;二是这些对象归主循环 + 持有,直接在 WebUI 线程弹出会留下仍在运行的后台任务(幽灵 runtime), + 并与主循环的字典写入竞态,因此统一投递到主循环,并复用 + heartflow_manager 的淘汰路径完成 stop。 + """ + + async def _stop_and_release() -> None: + core_chat_manager.sessions.pop(session_id, None) + await heartflow_manager.release_chat(session_id, reason="webui_delete") + + await run_on_main_loop(_stop_and_release()) -def _delete_chat_session_scope(session_id: str) -> Dict[str, Any]: +async def _delete_chat_session_scope(session_id: str) -> Dict[str, Any]: """删除聊天流及所有直接归属该 session_id 的数据库记录。""" with get_db_session() as session: @@ -1132,7 +1142,7 @@ def _delete_chat_session_scope(session_id: str) -> Dict[str, Any]: total_deleted += deleted_count items.append({"key": key, "label": label, "count": deleted_count}) - _release_deleted_chat_runtime(session_id) + await _release_deleted_chat_runtime(session_id) logger.warning( "已删除聊天流及关联数据: " f"session_id={session_id} total_deleted={total_deleted} items={items}" @@ -1313,7 +1323,7 @@ async def delete_chat_session(session_id: str) -> Dict[str, object]: if not normalized_session_id: raise HTTPException(status_code=400, detail="缺少聊天流 session_id") - return _delete_chat_session_scope(normalized_session_id) + return await _delete_chat_session_scope(normalized_session_id) @router.put("/sessions/{session_id}/talk-frequency") From 27bd0356b5bb88e2109417ef70f4e161423012b8 Mon Sep 17 00:00:00 2001 From: Natsuki Date: Tue, 14 Jul 2026 17:48:10 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(sender):=20=E5=87=BA=E7=AB=99=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=90=BD=E5=BA=93=E5=9B=9E=E5=88=B0=E4=B8=B2=E8=A1=8C?= =?UTF-8?q?=E5=86=99=E8=B7=AF=E5=BE=84=EF=BC=8C=E9=81=BF=E5=85=8D=E7=BB=95?= =?UTF-8?q?=E9=94=81=E4=B8=8E=E9=98=BB=E5=A1=9E=E4=BA=8B=E4=BB=B6=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + 落库,不做图片组件落盘), 发送侧改为调用该路径。 --- .../message_receive/uni_message_sender.py | 7 +++-- src/common/utils/utils_message.py | 26 ++++++++++++++++++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/chat/message_receive/uni_message_sender.py b/src/chat/message_receive/uni_message_sender.py index 93bd2c3c5b..29f8b15136 100644 --- a/src/chat/message_receive/uni_message_sender.py +++ b/src/chat/message_receive/uni_message_sender.py @@ -8,7 +8,6 @@ from src.chat.message_receive.message import SessionMessage from src.chat.utils.utils import calculate_typing_time, truncate_message from src.common.data_models.message_component_data_model import ReplyComponent -from src.common.database.database import get_db_session from src.common.logger import get_logger from src.common.message_server.api import get_global_api from src.common.utils.utils_message import MessageUtils @@ -354,9 +353,9 @@ async def send_message( # message.processed_plain_text = modified_message.plain_text if storage_message: - with get_db_session() as db_session: - MessageUtils.fill_reply_frequency_if_available(message) - db_session.add(message.to_db_instance()) + # 必须走 MessageUtils 的串行写入路径:直接同步写库会绕过 + # 进程级写锁并阻塞事件循环,DB 争用时消息已发出但落库失败。 + await MessageUtils.store_sent_message_to_db_async(message) try: from src.services.memory_flow_service import memory_automation_service diff --git a/src/common/utils/utils_message.py b/src/common/utils/utils_message.py index 23af394768..d3a5ea6e22 100644 --- a/src/common/utils/utils_message.py +++ b/src/common/utils/utils_message.py @@ -41,7 +41,7 @@ logger = get_logger("message_utils") -# 串行化 store_message_to_db / update_message_id 的 SQLite 写入: +# 串行化 store_message_to_db / store_sent_message_to_db / update_message_id 的 SQLite 写入: # 底层 SQLite WAL 仅允许单写,busy_timeout 1s。bot 进程不只一个 event loop # (bot.py 主 loop、WebUI 在另一个线程的独立 loop、临时 asyncio.run 调用等), # 因此 lock 必须是进程级的 threading.Lock 而不是 asyncio.Lock;后者只能互斥 @@ -231,6 +231,30 @@ async def store_message_to_db_async(message: "SessionMessage") -> None: """ await asyncio.to_thread(MessageUtils.store_message_to_db, message) + @staticmethod + def store_sent_message_to_db(message: "SessionMessage") -> None: + """存储 bot 已发送的消息到数据库。 + + 与 `store_message_to_db` 的差别是不做图片组件落盘(发送侧组件 + 不携带待持久化的二进制数据);写入同样必须持有 + `_DB_WRITE_THREAD_LOCK`,参见锁注释。 + """ + from src.common.database.database import get_db_session + + with _DB_WRITE_THREAD_LOCK: + with get_db_session() as session: + MessageUtils.fill_reply_frequency_if_available(message) + session.add(message.to_db_instance()) + + @staticmethod + async def store_sent_message_to_db_async(message: "SessionMessage") -> None: + """异步存储 bot 已发送的消息。 + + 把同步 SQLAlchemy session 移出事件循环;锁逻辑在 + `store_sent_message_to_db` 本体里持有,本方法仅做 `to_thread` 透传。 + """ + await asyncio.to_thread(MessageUtils.store_sent_message_to_db, message) + @staticmethod def fill_reply_frequency_if_available(message: "SessionMessage") -> None: """在消息入库前补充当前会话的生效回复频率。""" From d3d19172b9f716a3d17caf296bd7a4cc5b9dfc4e Mon Sep 17 00:00:00 2001 From: Natsuki Date: Tue, 14 Jul 2026 17:48:10 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test(webui):=20=E8=A1=A5=E5=85=85=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=A1=A9=E7=BC=BA=E5=A4=B1=E7=9A=84=20account=5Fid=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ada7c16 将 _timeline_chat_from_session 改为直接属性访问并为 ChatInfoResponse 新增 account_id 后,memory timeline 的 5 个 SimpleNamespace 测试桩与 jargon chats 的 1 个精确比对断言未同步, CI 上 6 个用例回归失败。为桩对象补充 account_id=None,并在期望 响应中加入 account_id 字段。 --- pytests/webui/test_jargon_routes.py | 1 + pytests/webui/test_memory_routes.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/pytests/webui/test_jargon_routes.py b/pytests/webui/test_jargon_routes.py index c1962d44be..5c8ebb80b7 100644 --- a/pytests/webui/test_jargon_routes.py +++ b/pytests/webui/test_jargon_routes.py @@ -622,6 +622,7 @@ def test_get_chat_list_includes_chat_session_without_jargon(client: TestClient, "session_id": sample_chat_session.session_id, "chat_name": sample_chat_session.group_name, "platform": sample_chat_session.platform, + "account_id": sample_chat_session.account_id, "is_group": True, } ] diff --git a/pytests/webui/test_memory_routes.py b/pytests/webui/test_memory_routes.py index c5c03c9dba..709ccaa602 100644 --- a/pytests/webui/test_memory_routes.py +++ b/pytests/webui/test_memory_routes.py @@ -638,6 +638,7 @@ def test_webui_memory_timeline_returns_chat_scoped_events(client: TestClient, mo platform="qq", group_id="100", user_id=None, + account_id=None, group_name="测试群", user_cardname=None, user_nickname=None, @@ -708,6 +709,7 @@ def test_webui_memory_timeline_filters_types_and_limit(client: TestClient, monke platform="qq", group_id="100", user_id=None, + account_id=None, group_name="测试群", user_cardname=None, user_nickname=None, @@ -761,6 +763,7 @@ def test_webui_memory_timeline_deleted_paragraph_prefers_delete_operation(client platform="qq", group_id="100", user_id=None, + account_id=None, group_name="测试群", user_cardname=None, user_nickname=None, @@ -791,6 +794,7 @@ def test_webui_memory_timeline_uses_latest_message_snapshot(client: TestClient, platform="qq", group_id=None, user_id="user-1", + account_id=None, group_name=None, user_cardname=None, user_nickname=None, @@ -845,6 +849,7 @@ def test_webui_memory_timeline_handles_json_bytes_zero_timestamp_and_batches_ite platform="qq", group_id="100", user_id=None, + account_id=None, group_name="测试群", user_cardname=None, user_nickname=None,