取消托管后发消息页面卡死:SSE 流不响应中止 + 托管取消竞态(附修复补丁与实测数据)
环境
- talkio 版本:2.8.0(与
main 分支 HEAD 一致)
- 平台:Windows 11 Pro(Tauri 2 + WebView2),桌面端网络层走
@tauri-apps/plugin-http
- Node(验证环境):v24.13.1
问题现象
群聊中开启「托管讨论」后点击「取消托管」,再想手动发消息与 AI 讨论时,页面卡死:输入框无法输入、发送按钮一直是停止状态、再点停止也无效,只能重启应用。该问题高频复现。
根因分析(4 个相互叠加的缺陷)
Bug 1(卡死主因):SSE 流读取不响应 AbortSignal,生成永远结束不了
三个 SSE 消费器的核心循环都是裸 await reader.read(),不监听中止信号:
src/services/openai-chat-sse.ts:19
src/services/anthropic-messages-sse.ts:31
src/services/openai-responses-sse.ts:41
桌面端 appFetch(src/lib/http.ts)优先使用 Tauri HTTP 插件的 fetch,该插件在响应体流式读取阶段不会中止挂起的 reader.read()。取消托管 → controller.abort() 被调用,但消费循环永远挂起:
generateForParticipant(src/stores/chat-generation.ts)的 finally 不执行 → dispatchMessageGeneration(src/stores/chat-dispatch.ts)的 finally 不执行 → isGenerating 永久为 true → ChatInput 的 <textarea disabled={isGenerating}>(src/components/shared/ChatInput.tsx:665)永久禁用,发送按钮永久显示为停止按钮。
雪上加霜的是 stopConversationGeneration(src/stores/chat-store-core.ts)第一次停止时就 abortControllers.delete(cid),之后再点停止拿不到 controller,彻底无解,只能重启。
Bug 2:取消托管后剩余轮数「复活」,托管取消不掉
runAutoDiscuss(src/stores/chat-dispatch.ts)在每轮 await sendMessage(...) 返回后无条件回写轮数:
await args.sendMessage(topicText.trim());
args.setStoreState({ autoDiscussRemaining: rounds - 1 }); // ← 用户取消后被复活
...
await args.sendMessage(continuePrompt);
args.setStoreState({ autoDiscussRemaining: Math.max(0, rounds - round - 1) }); // ← 同上
用户在 await 期间点了「取消托管」(autoDiscussRemaining 已置 0),await 一返回又被写回 >0 → 托管复活,继续自动发 "Continue"。实测取消 1 次后 5 轮仍被全部发完。
同时整个函数没有 try/finally:sendMessage 一旦抛异常(如 DB 写入失败),autoDiscussRemaining 永不复位,输入区被托管面板永久替换(ChatInput.tsx:498 的 isAutoDiscussing 分支),用户无法发任何消息——另一种「卡死」。
Bug 3:并发 dispatch 互相误删 AbortController
dispatchMessageGeneration 的 finally 无条件执行:
abortControllers.delete(cid); // 可能删掉的是新 dispatch 的 controller
setStoreState({ isGenerating: false, ... }); // 新 dispatch 还在跑就被标记结束
Bug 2 必然导致托管僵尸轮次(dispatch A)与用户新消息(dispatch B)并发。A 先结束时把 B 的 controller 删掉,B 从此无法停止(停止按钮失效),叠加 Bug 1 后再次卡死。
Bug 4:托管轮次会发送到错误的会话
runAutoDiscuss 捕获了启动时的 convId,但每轮调用的 sendMessage 内部重新读 get().currentConversationId(src/stores/chat-store.ts)。托管期间用户切换会话,后续轮次的 "Continue" 会发到新会话里。
修复方案(补丁见附件 fix-freeze-after-stop-autodiscuss.patch,10 个文件,+131/−33)
- 新增
src/services/sse-utils.ts:readWithAbort(reader, signal) —— 每次 read 与 abort 事件竞速,中止即 reader.cancel() 并抛 AbortError。无论底层传输是否响应 signal,消费循环都能在 abort 后立即退出,finally 链正常执行、isGenerating 正确复位。三个 SSE 消费器与三个 provider 适配器(chat-completions / anthropic-messages / responses)全部接入。
runAutoDiscuss 重写:引入递增的会话 token(autoDiscussSession),每次 await 后用 isSessionActive() && remaining > 0 复查,杜绝复活;try/finally 兜底清零轮数(异常时输入区不再卡死);每轮 sendMessage 显式传入启动时的 conversationId。
dispatchMessageGeneration 的 finally 增加持有权判断:仅当 Map 中的 controller 是自己时才删除并复位 isGenerating,并发 dispatch 不再互相踩踏。
stopConversationGeneration 只 abort 不 delete:controller 由 dispatch 的 finally 统一清理,重复点停止不再失效;stopAutoDiscuss 同时使 session token 失效并清零计数。
实测数据
复现/验证脚本(附件 freeze-repro.ts):测试 1 直接 import 修复后的真实源码 sse-utils.ts / openai-chat-sse.ts;测试 2/3 用与 chat-dispatch.ts 逐行一致的原版/修复版函数拷贝,由同一 mock 驱动对比。「挂起流」模拟 Tauri 插件在流读取阶段忽略 abort 的真实行为。实际运行输出(2026-07-30,Node v24.13.1 / win32 x64):
【测试1】取消时 SSE 流读取能否中断(挂起流 = Tauri 插件忽略 abort 的行为):
[原版] await reader.read() + 100ms 后 abort() → 2000ms 后仍挂起(永不返回 → isGenerating 永久=true → 页面卡死)
[修复] readWithAbort → abort 后 103.0ms 抛出 AbortError(循环立即退出, finally 正常执行)
[修复] consumeOpenAIChatCompletionsSse(真实源码) → abort 后 100.3ms 抛出 AbortError
【测试2】托管 5 轮, 第 1 轮生成中途点击「取消托管」:
[原版] 取消托管后 400ms: autoDiscussRemaining=0, 已自动发送消息数=5 (取消无效, 5轮仍被全部发完)
[修复] 取消托管后 400ms: autoDiscussRemaining=0, 已自动发送消息数=1 (托管已真正停止)
【测试2b】托管过程中 sendMessage 抛异常(如 DB 写入失败):
[原版] sendMessage 第2轮抛异常后: autoDiscussRemaining=4 (永不复位 → 输入区被托管面板永久替换, 无法发消息)
[修复] sendMessage 第2轮抛异常后: autoDiscussRemaining=0 (finally 兜底复位, 输入恢复正常)
【测试3】并发 dispatch(托管僵尸轮次 A + 用户新消息 B), A 先结束:
[原版] A 结束后 B 的 controller 还在 Map 中: false (B 从此无法停止 → 再次卡死)
[修复] A 结束后 B 的 controller 还在 Map 中: true (B 随时可停止)
全部测试完成, 总耗时 3112ms
| 场景 |
原版 2.8.0 |
修复后 |
| 取消时挂起的 SSE read |
永不返回(≥2000ms 观测仍挂起) |
~100ms 内抛 AbortError |
| 托管 5 轮第 1 轮中点取消 |
取消无效,5 轮全部发出 |
立即停止,仅 1 轮发出 |
| sendMessage 异常 |
轮数永不复位,输入区锁死 |
finally 兜底复位 |
| 并发 dispatch 先结束方 |
误删对方 controller |
互不影响 |
修复后整体验证:tsc --noEmit 0 错误;npm run build(tsc + vite)构建成功。
复现步骤(真机)
- 创建 ≥2 个模型的群聊,发送一条消息;
- 点击「托管讨论」选 5 或 10 轮;
- 在第 1~2 轮生成流式输出过程中点击「取消托管」;
- 尝试在输入框发消息 → 输入框禁用/页面卡死(Bug 1+3),或托管复活继续自动发 "Continue"(Bug 2)。
附件
fix-freeze-after-stop-autodiscuss.patch — 修复补丁(基于 main/2.8.0,10 个文件,+131/−33)
freeze-repro.ts — 复现/验证脚本(npx tsx freeze-repro.ts 可直接运行;测试 1 会 import 修复后源码中的 src/services/sse-utils.ts 与 src/services/openai-chat-sse.ts,请把脚本放在仓库根目录的同级文件夹内,或按实际路径调整文件顶部的两个 import)
talkio-freeze-fix-attachments.zip
大佬可以看一下,小的用k3跑的修复,如果我遇到的问题不属于原软件常规操作忽视即可,如果我给予的修复方案无用,可以再联系我。如果跑出的修复有用则是万幸。另:这个软件个人感受可以体验到不同模型输出的差距,感谢大佬的开源项目
取消托管后发消息页面卡死:SSE 流不响应中止 + 托管取消竞态(附修复补丁与实测数据)
环境
main分支 HEAD 一致)@tauri-apps/plugin-http问题现象
群聊中开启「托管讨论」后点击「取消托管」,再想手动发消息与 AI 讨论时,页面卡死:输入框无法输入、发送按钮一直是停止状态、再点停止也无效,只能重启应用。该问题高频复现。
根因分析(4 个相互叠加的缺陷)
Bug 1(卡死主因):SSE 流读取不响应 AbortSignal,生成永远结束不了
三个 SSE 消费器的核心循环都是裸
await reader.read(),不监听中止信号:src/services/openai-chat-sse.ts:19src/services/anthropic-messages-sse.ts:31src/services/openai-responses-sse.ts:41桌面端
appFetch(src/lib/http.ts)优先使用 Tauri HTTP 插件的 fetch,该插件在响应体流式读取阶段不会中止挂起的reader.read()。取消托管 →controller.abort()被调用,但消费循环永远挂起:generateForParticipant(src/stores/chat-generation.ts)的finally不执行 →dispatchMessageGeneration(src/stores/chat-dispatch.ts)的finally不执行 →isGenerating永久为true→ChatInput的<textarea disabled={isGenerating}>(src/components/shared/ChatInput.tsx:665)永久禁用,发送按钮永久显示为停止按钮。雪上加霜的是
stopConversationGeneration(src/stores/chat-store-core.ts)第一次停止时就abortControllers.delete(cid),之后再点停止拿不到 controller,彻底无解,只能重启。Bug 2:取消托管后剩余轮数「复活」,托管取消不掉
runAutoDiscuss(src/stores/chat-dispatch.ts)在每轮await sendMessage(...)返回后无条件回写轮数:用户在 await 期间点了「取消托管」(
autoDiscussRemaining已置 0),await 一返回又被写回 >0 → 托管复活,继续自动发 "Continue"。实测取消 1 次后 5 轮仍被全部发完。同时整个函数没有 try/finally:
sendMessage一旦抛异常(如 DB 写入失败),autoDiscussRemaining永不复位,输入区被托管面板永久替换(ChatInput.tsx:498的isAutoDiscussing分支),用户无法发任何消息——另一种「卡死」。Bug 3:并发 dispatch 互相误删 AbortController
dispatchMessageGeneration的finally无条件执行:Bug 2 必然导致托管僵尸轮次(dispatch A)与用户新消息(dispatch B)并发。A 先结束时把 B 的 controller 删掉,B 从此无法停止(停止按钮失效),叠加 Bug 1 后再次卡死。
Bug 4:托管轮次会发送到错误的会话
runAutoDiscuss捕获了启动时的convId,但每轮调用的sendMessage内部重新读get().currentConversationId(src/stores/chat-store.ts)。托管期间用户切换会话,后续轮次的 "Continue" 会发到新会话里。修复方案(补丁见附件
fix-freeze-after-stop-autodiscuss.patch,10 个文件,+131/−33)src/services/sse-utils.ts:readWithAbort(reader, signal)—— 每次 read 与 abort 事件竞速,中止即reader.cancel()并抛AbortError。无论底层传输是否响应 signal,消费循环都能在 abort 后立即退出,finally链正常执行、isGenerating正确复位。三个 SSE 消费器与三个 provider 适配器(chat-completions / anthropic-messages / responses)全部接入。runAutoDiscuss重写:引入递增的会话 token(autoDiscussSession),每次 await 后用isSessionActive() && remaining > 0复查,杜绝复活;try/finally兜底清零轮数(异常时输入区不再卡死);每轮sendMessage显式传入启动时的conversationId。dispatchMessageGeneration的 finally 增加持有权判断:仅当 Map 中的 controller 是自己时才删除并复位isGenerating,并发 dispatch 不再互相踩踏。stopConversationGeneration只 abort 不 delete:controller 由 dispatch 的 finally 统一清理,重复点停止不再失效;stopAutoDiscuss同时使 session token 失效并清零计数。实测数据
复现/验证脚本(附件
freeze-repro.ts):测试 1 直接 import 修复后的真实源码sse-utils.ts/openai-chat-sse.ts;测试 2/3 用与chat-dispatch.ts逐行一致的原版/修复版函数拷贝,由同一 mock 驱动对比。「挂起流」模拟 Tauri 插件在流读取阶段忽略 abort 的真实行为。实际运行输出(2026-07-30,Node v24.13.1 / win32 x64):修复后整体验证:
tsc --noEmit0 错误;npm run build(tsc + vite)构建成功。复现步骤(真机)
附件
fix-freeze-after-stop-autodiscuss.patch— 修复补丁(基于 main/2.8.0,10 个文件,+131/−33)freeze-repro.ts— 复现/验证脚本(npx tsx freeze-repro.ts可直接运行;测试 1 会 import 修复后源码中的src/services/sse-utils.ts与src/services/openai-chat-sse.ts,请把脚本放在仓库根目录的同级文件夹内,或按实际路径调整文件顶部的两个 import)talkio-freeze-fix-attachments.zip
大佬可以看一下,小的用k3跑的修复,如果我遇到的问题不属于原软件常规操作忽视即可,如果我给予的修复方案无用,可以再联系我。如果跑出的修复有用则是万幸。另:这个软件个人感受可以体验到不同模型输出的差距,感谢大佬的开源项目