Skip to content

fix(polish): 润色流式请求让取消检测和网络等待赛跑,不再悬挂到 budget 结束 - #1001

Merged
H-Chris233 merged 3 commits into
Open-Less:betafrom
MurphyLo:fix/polish-stream-cancel-latency
Aug 26, 2026
Merged

fix(polish): 润色流式请求让取消检测和网络等待赛跑,不再悬挂到 budget 结束#1001
H-Chris233 merged 3 commits into
Open-Less:betafrom
MurphyLo:fix/polish-stream-cancel-latency

Conversation

@MurphyLo

@MurphyLo MurphyLo commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

摘要

Fixes #1000

润色阶段模型迟迟不返回时,取消要等 32~53s 才生效,期间胶囊卡死、快捷键无响应,只能强制重启 App。根因是 chat_completion_messages_streaming 里等待网络的 await 都不参与取消检测,should_cancel 只在 SSE 循环顶部被轮询一次。同类问题在转写阶段已由 #798 修过,没有推广到润色阶段。

本 PR 用同款 tokio::select! 赛跑补齐该函数里的三处网络等待,取消粒度从「最长等一个 budget」收窄到 ~75ms。

修复 / 新增 / 改进

  • 新增 wait_until_cancelled(75ms 轮询,与 wait_for_processing_cancel 同间隔),分别与三处网络等待赛跑:建连 send_with_transient_retry、SSE 逐帧 response.chunk()、非 2xx 错误体 response.text()
  • 第三处是外部 review 才发现的遗漏:polish_clientPOLISH_CLIENT_HARD_CAP_SECS(900s)作总超时,服务端只要回一个 500 头就挂住不发 body,取消完全不生效,最坏卡死 15 分钟——比原路径的 30s 首字预算严重一个数量级。
  • 三处 select! 均加 biased,让取消分支先于网络分支被 poll,消掉「budget 归零与取消同时就绪时随机选分支」的竞态。
  • 建连阶段取消返回的 status 由 200 改为 0:该路径上一个 HTTP 响应字节都没收到,报 200 会把日志排查带偏。
  • 三个回归测试各锁一处等待点,均做过反向验证(移除对应赛跑后该用例失败):cancellation_mid_stream_* 对应 issue 日志里 after 0 deltas ... breaking SSE loop 的真实路径;cancellation_while_reading_error_body_* 在移除赛跑后实测耗时 5.01s。
  • 未改动:dictation.rs 状态机、already_streamed 语义、各项超时预算数值。
  • 未改动:chat_completion_history_streaming 与 Codex OAuth 流式路径存在同款写法,不在本次复现路径内,留作单独跟进。

兼容

  • 不包含:不改超时预算数值(首字 30s+ / 空闲 20s / 硬顶 900s),不改 already_streamed 语义与 dictation.rs 状态机。
  • 对现有用户 / 本地环境 / 构建流程的影响:无。只缩短「点了取消后还要等多久」,未取消路径的行为不变。
  • 改动量:生产代码净增 48 行(含注释),测试净增 153 行。

测试计划

  • 命令:cargo test --manifest-path src-tauri/Cargo.toml --lib(macOS/aarch64)
  • 结果:1227 passed; 0 failed
  • 反向验证:逐个移除三处赛跑后重跑,对应用例分别失败(SSE 循环那处取消被忽略并返回 Ok;错误体那处耗时 5.01s)
  • 证据路径:本地终端输出,未落盘到仓库

配置为 qwen-audio-3.0-asr-flash + deepseek-v4-flash 时,若润色阶段对话模型
接口迟迟不返回,取消操作(点击胶囊关闭按钮 / 按快捷键重新发起)会晚至
32~53 秒才生效,期间胶囊卡死在屏幕上、快捷键无响应,只能强制重启 App
才能恢复(本机日志复现,见 issue)。

根因:chat_completion_messages_streaming 的取消检测只在 SSE 循环顶部轮询
一次,真正等待网络数据的两处 await(建连/发送请求、逐帧读取)都不会被
取消打断,只能等到数据到达或 budget(首字 30s+ / 空闲 20s / 硬顶 900s)
自然超时。同一类问题在转写(ASR)阶段已在 Open-Less#798 修过,但没有推广到润色
阶段——这是一处遗留的修复范围缺口。

用同款 tokio::select! 赛跑模式补齐:新增 wait_until_cancelled helper
(75ms 轮询,与 wait_for_processing_cancel 同款间隔),分别和
send_with_transient_retry、response.chunk() 赛跑,命中取消就复用既有的
"空流 → InvalidResponse" 错误路径。不改变任何超时预算数值,不改变
already_streamed 语义,不改变 dictation.rs 状态机。

Fixes Open-Less#1000
上一版新增的 cancellation_before_response_arrives_* 只覆盖了「服务端连状态行
都不回」的建连阶段,走的是 send_with_transient_retry 之前那个 select!。而
issue Open-Less#1000 日志里打出的是 "cancelled by caller after 0 deltas ... breaking
SSE loop",说明 response 头已到达、代码已进入 SSE 循环,真正卡住的是
response.chunk() 那一次 await——也就是本次修复的核心那处 select!,此前没有
任何回归测试覆盖:把它还原成旧写法,原用例照样通过。

补 cancellation_mid_stream_does_not_wait_out_the_budget:服务端立刻回 200
header 让客户端进入 SSE 循环,随后长时间不发 delta,取消后必须在一个轮询
周期内返回,而不是等满 30s 首字预算。

顺带把建连阶段取消返回的 status 从编造的 200 改成 0:那条路径上一个 HTTP
响应字节都没收到,日志打成 "status 200" 会让人误以为服务端回了 200 空body。
两个用例的断言因此分别锁 status 0 / 200,互相不会冒充。
外部 review(codex)指出前两版仍漏了一处不可取消的网络等待:收到非 2xx
响应头后的 response.text()。polish_client 走的是 POLISH_CLIENT_HARD_CAP_SECS
(900s)总超时,服务端只要回一个 500 头就挂住不发 body,取消完全不生效,
最坏卡死 15 分钟——症状与 issue Open-Less#1000 一致,且比原路径的 30s 首字预算严重
一个数量级。

补 cancellation_while_reading_error_body_does_not_hang:服务端回 500 头并
声明 1KB body 却一字节不发。去掉这次赛跑后该用例实测耗时 5.01s(真实场景
连接不关就是等满 900s)。

三处 select! 统一加 biased,让取消分支先于网络分支被 poll,消掉「budget 归
零与取消同时就绪时随机选分支」的竞态。

按 review 意见收紧措辞与注释:原注释称 Response 被 drop 会中断底层 TCP,
在 HTTP/2 多路复用与连接池下未必成立,改为只声明放弃这次请求的等待;
wait_until_cancelled 与两个既有用例的说明各压掉约一半。
@H-Chris233
H-Chris233 merged commit 18e1aaf into Open-Less:beta Aug 26, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(polish): 润色阶段卡住时取消要等数十秒才生效,胶囊卡死需强制重启

2 participants