现象
配置为 qwen-audio-3.0-asr-flash(ASR)+ deepseek-v4-flash(润色)时,偶发润色阶段卡死:
- 转写完成、进入润色(LLM streaming)后,若对话模型接口迟迟不返回内容,语音输入胶囊会一直悬浮在屏幕上,不会消失。
- 此时点击胶囊上的关闭按钮(×)无效。
- 再按快捷键(Option)尝试开始新一轮语音输入也没有反应。
- 唯一的恢复方式是强制退出并重启 OpenLess App。
根因(已用本机日志 + 源码定位)
polish.rs::chat_completion_messages_streaming 的取消检测(should_cancel())只在 SSE 循环顶部轮询一次,真正等待网络数据的两处 await:
- 建连/发送请求:
send_with_transient_retry(request).await?
- 逐帧读取:
tokio::time::timeout(budget, response.chunk()).await
都不会被取消打断——一旦进了某一次 await,要么等到数据到达,要么等到 budget(首字最长 30s+、之后每帧最长 20s 空闲、硬顶 900s)自然超时,取消信号才会被看到。本机日志复现了这个时间差,例如:
2026-08-25T08:22:06.819608Z [INFO] [llm] polish_streaming provider=deepseek model=deepseek-v4-flash prior_turns=0 raw_chars=139
2026-08-25T08:22:49.972444Z [INFO] [coord] session cancelled (was Processing)
2026-08-25T08:23:22.154183Z [INFO] [llm] polish stream cancelled by caller after 0 deltas (0 chars); breaking SSE loop
2026-08-25T08:23:22.155346Z [ERROR][coord] streaming polish FAILED: invalid response: status 200, body: empty polish stream
用户在 08:22:49 点击取消,直到 08:23:22(32 秒后)取消才真正生效——这段时间里连接仍然保持打开、零字节返回。同日另一次实测延迟达 53.7 秒,还有一次用户连续点了 6 次取消都不生效,最终只能强制重启 App。
胶囊无法关闭 / Option 无响应是这个根因的下游表现:coordinator/dictation.rs::cancel_session() 在 Processing 阶段刻意保持 phase=Processing(等 end_session 自然走完收尾),而 handle_pressed() 的状态机只在 Idle 阶段响应新一轮请求——只要润色卡住不放,phase 就一直卡在 Processing,胶囊和快捷键自然都失效。这两处本身逻辑没问题,是上游取消迟迟不生效在放大影响。
对照
同一类问题("进入 Processing 阶段后取消不会真正停下来")在 转写(ASR)阶段 已经在 #798 修复过:引入了 TranscribeRace + wait_for_processing_cancel(75ms 轮询)+ tokio::select!,让取消信号能打断在途的转写请求。但 #798 的修复范围只覆盖了 ASR 子阶段,没有推广到润色(LLM streaming)子阶段——这是一处遗留的修复范围缺口,不是有意为之的设计。
复现条件
- 使用会偶发响应慢/挂起的对话模型接口(如反向代理、网络抖动、供应商侧限流静默丢包等)。
- 润色阶段迟迟无首字或迟迟无新增字符。
- 此时点击取消或按快捷键重新发起。
关联 PR:将提交一个最小化修复,用同款 tokio::select! 赛跑模式覆盖 chat_completion_messages_streaming 里的两处网络 await,取消检测粒度从"最长等一个 budget"收窄到"~75ms"。
现象
配置为
qwen-audio-3.0-asr-flash(ASR)+deepseek-v4-flash(润色)时,偶发润色阶段卡死:根因(已用本机日志 + 源码定位)
polish.rs::chat_completion_messages_streaming的取消检测(should_cancel())只在 SSE 循环顶部轮询一次,真正等待网络数据的两处 await:send_with_transient_retry(request).await?tokio::time::timeout(budget, response.chunk()).await都不会被取消打断——一旦进了某一次 await,要么等到数据到达,要么等到
budget(首字最长 30s+、之后每帧最长 20s 空闲、硬顶 900s)自然超时,取消信号才会被看到。本机日志复现了这个时间差,例如:用户在
08:22:49点击取消,直到08:23:22(32 秒后)取消才真正生效——这段时间里连接仍然保持打开、零字节返回。同日另一次实测延迟达 53.7 秒,还有一次用户连续点了 6 次取消都不生效,最终只能强制重启 App。胶囊无法关闭 / Option 无响应是这个根因的下游表现:
coordinator/dictation.rs::cancel_session()在Processing阶段刻意保持phase=Processing(等end_session自然走完收尾),而handle_pressed()的状态机只在Idle阶段响应新一轮请求——只要润色卡住不放,phase就一直卡在Processing,胶囊和快捷键自然都失效。这两处本身逻辑没问题,是上游取消迟迟不生效在放大影响。对照
同一类问题("进入 Processing 阶段后取消不会真正停下来")在 转写(ASR)阶段 已经在 #798 修复过:引入了
TranscribeRace+wait_for_processing_cancel(75ms 轮询)+tokio::select!,让取消信号能打断在途的转写请求。但 #798 的修复范围只覆盖了 ASR 子阶段,没有推广到润色(LLM streaming)子阶段——这是一处遗留的修复范围缺口,不是有意为之的设计。复现条件
关联 PR:将提交一个最小化修复,用同款
tokio::select!赛跑模式覆盖chat_completion_messages_streaming里的两处网络 await,取消检测粒度从"最长等一个 budget"收窄到"~75ms"。