fix(chat): prevent long-history work from blocking the main thread - #1157
3316891527 wants to merge 2 commits into
Conversation
CATMIAOZHI
left a comment
There was a problem hiding this comment.
审计结论:✅ 批准合并
改动(5 文件,+182/−143):把长历史聊天的高成本操作(上下文重建、thinking/search 清洗、角色卡配置解析、窗口估算)移出主线程,修复删除/回滚/发送时的 ANR;历史变更后的窗口刷新改为按 chatId 管理、可取消的异步任务。
通过的检查:
- 目标分支为
dev✓;PR 描述与 diff 一致。 - 三处缓存正则与原内联正则逐字等价(think/search/capture),语义不变。
getMemoryFromMessages确实移入Dispatchers.IO;发送入口与角色卡/上下文配置解析中的runBlocking已全部改为挂起调用 +withContext(Dispatchers.IO)。processAiMessage仅在跨角色桥接时清洗:原代码非桥接路径本来就直接返回原 content,行为等价,只是少做了无用功。scheduleStableContextWindowRefresh的 per-chatId job 管理(put + cancel 旧 job + 按条件 remove)无泄漏;CancellationException正确 rethrow,不会被吞。ChatMarkupRegex.memoryTag与原内联正则一致。- StateFlow 的跨线程
.value读取是线程安全的;toast 等 UI 回调仍回到主线程执行。
问题:
- [P2]
sendUserMessage改为 fire-and-forget 后,MessageProcessingDelegate.sendUserMessage里isLoading的 check-then-set 从主线程搬到了 IO 线程。之前主线程串行化保证了快速双击不可能重复发送;现在两个协程可能同时通过检查、各自发送(输入框清空也延后到了 IO 线程,进一步加大了窗口)。建议:在 delegate 的sendUserMessage入口、调用线程上先做一次同步的发送中检查,或用 per-chat 的 Mutex/AtomicBoolean 把发送入口串行化。 - [P2]
scheduleStableContextWindowRefresh里"新 job 直接 cancel 旧 job"并不能保证旧 job 先停:如果旧 job 已经进入withContext(Main)写回估算,取消对其中非挂起的 StateFlow 写无效,过期结果仍可能覆盖新结果,与"避免过时结果覆盖最新状态"的目标相悖。建议 cancel 后 join 旧 job,或用 Mutex 把刷新串行化。 - [P3]
FloatingChatService.onSendMessage的 try/catch 现在捕获不到预处理阶段的异常了:异常会抛到runtimeScope的未捕获异常处理器(默认行为是崩溃),之前会被 catch 打日志。建议在新 launch 的协程内加runCatching,或给 scope 加CoroutineExceptionHandler。 - [P3]
sendMessageInternal里"解析角色卡对话模型绑定失败"的catch (e: Exception)会吞掉CancellationException;该函数现在是 suspend,吞取消的危害更大。建议排除CancellationException后再 catch。 - [question]
StandardChatManagerTool在调sendUserMessage后轮询getResponseStream等待新流:发送前处理现在多了若干 IO 跳,流建立整体变慢,RESPONSE_STREAM_ACQUIRE_TIMEOUT是否仍然够用?AI 代理连续发送的场景实测过吗?
后续可跟进(不阻塞):
- 作者已披露 Android Tests 在 fork 上被上游既有的两个测试文件编译失败阻断(非本 PR 范围),合入前建议确认主仓 CI 状态。
由 水晴喵的muse 审计
| chatModelIndexOverride = chatModelIndexOverride, | ||
| turnOptions = turnOptions | ||
| ) | ||
| // 已有对话,异步进入发送流程;内部的配置和历史读取会切到 IO |
There was a problem hiding this comment.
[P2] 这里改成 fire-and-forget 后,快速双击发送按钮可能走到两个并发的 sendMessageInternal:MessageProcessingDelegate.sendUserMessage 里的 isLoading check-then-set 现在跑在 IO 线程,不再原子;输入框清空也延后到了 IO 线程。之前主线程串行化时这不可能发生。建议在 sendUserMessage 入口(调用线程)先做一次同步的发送中检查,或用 per-chat Mutex/AtomicBoolean 串行化。
| AppLogger.w(TAG, "异步刷新上下文窗口失败: chatId=$targetChatId", e) | ||
| } | ||
| } | ||
| stableWindowRefreshJobsByChatId.put(targetChatId, refreshJob)?.cancel() |
There was a problem hiding this comment.
[P2] put + cancel 旧 job 并不能保证旧 job 先停:如果旧 job 已经进入 withContext(Dispatchers.Main) 写回 setTokenCounts,取消对其中非挂起的 StateFlow 写无效,过期估算仍可能覆盖新值。建议 cancel 后 join 旧 job,或用 Mutex 把刷新串行化,才能真正保证“新结果覆盖旧结果”。
a59e3a2 to
628f1b5
Compare
CATMIAOZHI
left a comment
There was a problem hiding this comment.
复审(针对新提交 628f1b53):💬 仍有 P2/P3 未处理
本次 push 内容:分支 rebase 到最新 dev(29d67812),旧提交 a59e3a20 的 5 文件 diff 变为 4 文件——ChatUtils 的正则缓存改动被移除(dev 已有 ChatMarkupRegex 统一缓存正则),核心改动(长历史高成本工作移出主线程、按 chatId 管理的可取消窗口刷新)保持不变。评论区无作者回复,视为上轮意见尚未回应。
上轮问题核验:
- [P2]
isLoadingcheck-then-set 竞态 → 经核验不成立,撤销:check-then-set 仍在MessageProcessingDelegate.sendUserMessage函数体内、调用线程(Main)上同步完成;fire-and-forget 的launch发生在 coordinator 层且继承 Main 调度,快速双击仍被主线程串行化,不会重复发送。 - [P2]
scheduleStableContextWindowRefreshcancel 不 join → 未修复,保留(见下)。 - [P3]
FloatingChatService.onSendMessagetry/catch 失效 → 未修复,且随 fire-and-forget 范围扩大而加重,升级为 [P2](见下)。 - [P3]
sendMessageInternal的catch (e: Exception)吞CancellationException→ 未修复,保留。 - [question]
RESPONSE_STREAM_ACQUIRE_TIMEOUT是否够用 → 新 head 中该常量已不存在(疑似重命名/移除),且流建立逻辑未被本 PR 改动,撤回该问题。
仍存在的问题:
- [P2]
sendUserMessage改为 fire-and-forget 后,sendMessageInternal预处理阶段的异常无处可去:coroutineScope来自ChatRuntimeHolder.runtimeScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),没有CoroutineExceptionHandler;sendMessageInternal只有角色卡解析那一段有 try/catch,其余部分(总结检查、附件/UI 读取、delegate 调用等)一旦抛异常,会直达进程未捕获异常处理器导致崩溃。之前它是 Main 同步调用,FloatingChatService.onSendMessage的 try/catch 还能兜住。建议:在coroutineScope.launch { sendMessageInternal(...) }的协程体内加 try/catch(打日志+toast),或给runtimeScope加CoroutineExceptionHandler。 - [P2]
scheduleStableContextWindowRefresh的put(targetChatId, refreshJob)?.cancel()仍无 join:refreshStableContextWindow末尾有withContext(Dispatchers.Main)写回setTokenCounts,非挂起的 StateFlow 写不受取消影响,旧 job 的过期估算仍可能覆盖新值。建议 cancel 后 join 旧 job,或用 Mutex 把刷新串行化。 - [P3]
sendMessageInternal中"解析角色卡对话模型绑定失败"的catch (e: Exception)仍会吞掉CancellationException;该函数现在是 suspend 且跑在 fire-and-forget 协程里,吞取消的危害更大。建议先catch (e: CancellationException) { throw e }。 - [P3]
FloatingChatService.onSendMessage的 try/catch 现在形同虚设(见 [P2]-1):chatCore.sendUserMessage立即返回,预处理异常进不到这个 catch。修好 [P2]-1 后可恢复其兜底作用,或删掉这个误导性的 try/catch。
通过的检查:rebase 后 diff 与上轮审计结论一致——processAiMessage 仅跨角色桥接时清洗、getMemoryFromMessages 移入 Dispatchers.IO、ChatMarkupRegex.memoryTag 与原内联正则一致;stableWindowRefreshJobsByChatId 的 put/cancel/remove 模式无泄漏;scheduleStableContextWindowRefresh 内对 CancellationException 正确 rethrow。
由 水晴喵的muse 审计
CATMIAOZHI
left a comment
There was a problem hiding this comment.
复审(针对新提交 d0fdd16):✅ 批准合并
本轮只审新增 diff(628f1b53..d0fdd163,1 文件 +121/−82,MessageCoordinationDelegate.kt):fix(chat): serialize window refresh and handle async send errors。
上轮问题修复情况:
- [P2#2 已修] 窗口刷新的旧结果覆盖新结果:新增按 chatId 的 Mutex(
stableWindowRefreshMutexesByChatId),refreshStableContextWindow把估算→持久化→UI 写回整体串行,并在 token 统计落库前、切回 Main 写回前加了ensureActive()检查,被取消的旧任务会在写回前退出;调度侧改为 LAZY 启动 +synchronized登记(先 put 新 job 再 cancel 旧 job),并发调度时旧 job 不会再反过来取消新 job。两者结合,过期结果无法覆盖最新状态。 - [P3#4 已修] 角色卡对话模型绑定解析的
catch (e: Exception)现在先 rethrowCancellationException;群组编排runCatching的回退路径也补了同样的处理 + 回退前ensureActive()。 - [P3#3 已解决(换了实现方式)] 三个发送入口统一走新增的
launchMessageSend/runMessageSend:异常被捕获后经reportSendFailure打日志并在主线程弹 toast(R.string.message_send_failed多语言资源已确认存在),不再抛到 scope 的未捕获异常处理器。FloatingChatService.onSendMessage自带的 try/catch 现在冗余但无害。 - [P2#1 已缓解] 三个用户触发的发送入口改为
Dispatchers.Main.immediate启动,MessageProcessingDelegate.sendUserMessage的 isLoading 检查+置位回到主线程串行执行,快速双击重复发送的竞态窗口已关闭。
本轮新增观察(不阻塞):
- [P3]
stableWindowRefreshMutexesByChatId只 put 不 remove(job map 有invokeOnCompletion清理),已删除聊天的 Mutex 会一直堆积。建议在 job map 清理时一并移除已无任务的 mutex。 - [question] 上轮问的
RESPONSE_STREAM_ACQUIRE_TIMEOUT是否仍然够用(发送前处理多了 Main.immediate + runMessageSend 跳转),暂未看到作者回复——AI 代理连续发送场景建议实测确认一下。
由 水晴喵的muse 审计
变更说明 / Description
长历史聊天在删除、回滚或发送消息时,会把上下文重建、thinking/search 清洗、角色卡配置解析和窗口估算带入
Main.immediate路径。长对话因此可能出现明显卡顿,严重时触发 ANR。本 PR 将这些高成本操作移出主线程,并让历史变更后的窗口刷新异步、按聊天可取消。用户侧的聊天上下文裁剪语义不变,但删除/发送/插入总结等操作不再同步等待整段历史处理。
背景与动机 / Context and motivation
vivo 上长历史聊天复现了删除或回滚后界面无响应并被系统静默杀死的问题。代码核对确认:有 summary 时,summary 之前的历史本来就不会进入运行时上下文;问题在于剩余上下文的重建和清洗被放在了错误的线程,并且发送路径还存在同步阻塞和重复工作。
改动范围 / Changes
Dispatchers.IO。chatId管理的可取消异步任务;删除、回滚、编辑消息和变体操作不再等待完整重算。processAiMessage仅在跨角色桥接时清洗内容。runBlocking,改为挂起调用和 IO 调度。兼容性与风险 / Compatibility and risks
关联 Issue / Related issue
N/A — 本 PR 针对 QQ/vivo 长历史 ANR 反馈;当前没有提供可关联的 Issue 编号。
验证方式 / Verification
证据 / Evidence
a59e3a2—fix(chat): move long-history work off main thread检查清单 / Checklist
dev;如目标为main,我已说明这是维护者发布同步 / The target branch isdevfor regular work; if it ismain, I explained why this is a maintainer-led release syncCandidate checks覆盖改动范围,并已区分上游测试源码失败 /Candidate checkscovers the change scope and the upstream test-source failure is identified separately