Skip to content

取消托管后发消息页面卡死:SSE 流不响应中止 + 托管取消竞态(附修复补丁与实测数据) #19

Description

@qing010101

取消托管后发消息页面卡死: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

桌面端 appFetchsrc/lib/http.ts)优先使用 Tauri HTTP 插件的 fetch,该插件在响应体流式读取阶段不会中止挂起的 reader.read()。取消托管 → controller.abort() 被调用,但消费循环永远挂起:

generateForParticipantsrc/stores/chat-generation.ts)的 finally 不执行 → dispatchMessageGenerationsrc/stores/chat-dispatch.ts)的 finally 不执行 → isGenerating 永久为 trueChatInput<textarea disabled={isGenerating}>src/components/shared/ChatInput.tsx:665)永久禁用,发送按钮永久显示为停止按钮。

雪上加霜的是 stopConversationGenerationsrc/stores/chat-store-core.ts)第一次停止时就 abortControllers.delete(cid),之后再点停止拿不到 controller,彻底无解,只能重启。

Bug 2:取消托管后剩余轮数「复活」,托管取消不掉

runAutoDiscusssrc/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/finallysendMessage 一旦抛异常(如 DB 写入失败),autoDiscussRemaining 永不复位,输入区被托管面板永久替换(ChatInput.tsx:498isAutoDiscussing 分支),用户无法发任何消息——另一种「卡死」。

Bug 3:并发 dispatch 互相误删 AbortController

dispatchMessageGenerationfinally 无条件执行:

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().currentConversationIdsrc/stores/chat-store.ts)。托管期间用户切换会话,后续轮次的 "Continue" 会发到新会话里。

修复方案(补丁见附件 fix-freeze-after-stop-autodiscuss.patch,10 个文件,+131/−33)

  1. 新增 src/services/sse-utils.tsreadWithAbort(reader, signal) —— 每次 read 与 abort 事件竞速,中止即 reader.cancel() 并抛 AbortError无论底层传输是否响应 signal,消费循环都能在 abort 后立即退出finally 链正常执行、isGenerating 正确复位。三个 SSE 消费器与三个 provider 适配器(chat-completions / anthropic-messages / responses)全部接入。
  2. runAutoDiscuss 重写:引入递增的会话 token(autoDiscussSession),每次 await 后用 isSessionActive() && remaining > 0 复查,杜绝复活;try/finally 兜底清零轮数(异常时输入区不再卡死);每轮 sendMessage 显式传入启动时的 conversationId
  3. dispatchMessageGeneration 的 finally 增加持有权判断:仅当 Map 中的 controller 是自己时才删除并复位 isGenerating,并发 dispatch 不再互相踩踏。
  4. 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)构建成功。

复现步骤(真机)

  1. 创建 ≥2 个模型的群聊,发送一条消息;
  2. 点击「托管讨论」选 5 或 10 轮;
  3. 在第 1~2 轮生成流式输出过程中点击「取消托管」;
  4. 尝试在输入框发消息 → 输入框禁用/页面卡死(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.tssrc/services/openai-chat-sse.ts,请把脚本放在仓库根目录的同级文件夹内,或按实际路径调整文件顶部的两个 import)

talkio-freeze-fix-attachments.zip
大佬可以看一下,小的用k3跑的修复,如果我遇到的问题不属于原软件常规操作忽视即可,如果我给予的修复方案无用,可以再联系我。如果跑出的修复有用则是万幸。另:这个软件个人感受可以体验到不同模型输出的差距,感谢大佬的开源项目

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions